[
  {
    "path": ".all-contributorsrc",
    "content": "{\n  \"projectName\": \"foam\",\n  \"projectOwner\": \"foambubble\",\n  \"repoType\": \"github\",\n  \"repoHost\": \"https://github.com\",\n  \"files\": [\n    \"docs/index.md\",\n    \"readme.md\"\n  ],\n  \"imageSize\": 60,\n  \"commit\": false,\n  \"commitConvention\": \"none\",\n  \"contributors\": [\n    {\n      \"login\": \"jevakallio\",\n      \"name\": \"Jani Eväkallio\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/1203949?v=4\",\n      \"profile\": \"https://jevakallio.dev/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jsjoeio\",\n      \"name\": \"Joe Previte\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/3806031?v=4\",\n      \"profile\": \"https://joeprevite.com/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"riccardoferretti\",\n      \"name\": \"Riccardo\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/457005?v=4\",\n      \"profile\": \"https://github.com/riccardoferretti\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jojanaho\",\n      \"name\": \"Janne Ojanaho\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/2180090?v=4\",\n      \"profile\": \"http://ojanaho.com/\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"paulshen\",\n      \"name\": \"Paul Shen\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/2266187?v=4\",\n      \"profile\": \"http://bypaulshen.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"coffenbacher\",\n      \"name\": \"coffenbacher\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/245867?v=4\",\n      \"profile\": \"https://github.com/coffenbacher\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"mathieudutour\",\n      \"name\": \"Mathieu Dutour\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/3254314?v=4\",\n      \"profile\": \"https://mathieu.dutour.me/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"presidentelect\",\n      \"name\": \"Michael Hansen\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/1242300?v=4\",\n      \"profile\": \"https://github.com/presidentelect\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"dnadlinger\",\n      \"name\": \"David Nadlinger\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/19335?v=4\",\n      \"profile\": \"http://klickverbot.at/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"MrCordeiro\",\n      \"name\": \"Fernando\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/20598571?v=4\",\n      \"profile\": \"https://pluckd.co/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jfgonzalez7\",\n      \"name\": \"Juan Gonzalez\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/58857736?v=4\",\n      \"profile\": \"https://github.com/jfgonzalez7\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"louiechristie\",\n      \"name\": \"Louie Christie\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/6807448?v=4\",\n      \"profile\": \"http://www.louiechristie.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"SuperSandro2000\",\n      \"name\": \"Sandro\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/7258858?v=4\",\n      \"profile\": \"https://supersandro.de/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Skn0tt\",\n      \"name\": \"Simon Knott\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/14912729?v=4\",\n      \"profile\": \"https://github.com/Skn0tt\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"styfle\",\n      \"name\": \"Steven\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/229881?v=4\",\n      \"profile\": \"https://styfle.dev/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Georift\",\n      \"name\": \"Tim\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/859430?v=4\",\n      \"profile\": \"https://github.com/Georift\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"sauravkhdoolia\",\n      \"name\": \"Saurav Khdoolia\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/34188267?v=4\",\n      \"profile\": \"https://github.com/sauravkhdoolia\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"anku255\",\n      \"name\": \"Ankit Tiwari\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/22813027?v=4\",\n      \"profile\": \"https://anku.netlify.com/\",\n      \"contributions\": [\n        \"doc\",\n        \"test\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ayushbaweja\",\n      \"name\": \"Ayush Baweja\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/44344063?v=4\",\n      \"profile\": \"https://github.com/ayushbaweja\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"TaiChi-IO\",\n      \"name\": \"TaiChi-IO\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/65092992?v=4\",\n      \"profile\": \"https://github.com/TaiChi-IO\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"juanfrank77\",\n      \"name\": \"Juan F Gonzalez \",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/12146882?v=4\",\n      \"profile\": \"https://github.com/juanfrank77\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"SanketDG\",\n      \"name\": \"Sanket Dasgupta\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/8980971?v=4\",\n      \"profile\": \"https://sanketdg.github.io\",\n      \"contributions\": [\n        \"doc\",\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"nstafie\",\n      \"name\": \"Nicholas Stafie\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/10801854?v=4\",\n      \"profile\": \"https://github.com/nstafie\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"francishamel\",\n      \"name\": \"Francis Hamel\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/36383308?v=4\",\n      \"profile\": \"https://github.com/francishamel\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"digiguru\",\n      \"name\": \"digiguru\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/619436?v=4\",\n      \"profile\": \"http://digiguru.co.uk\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"chirag-singhal\",\n      \"name\": \"CHIRAG SINGHAL\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/42653703?v=4\",\n      \"profile\": \"https://github.com/chirag-singhal\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"lostintangent\",\n      \"name\": \"Jonathan Carter\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/116461?v=4\",\n      \"profile\": \"https://github.com/lostintangent\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"synesthesia\",\n      \"name\": \"Julian Elve\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/181399?v=4\",\n      \"profile\": \"https://www.synesthesia.co.uk\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"thomaskoppelaar\",\n      \"name\": \"Thomas Koppelaar\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/36331365?v=4\",\n      \"profile\": \"https://github.com/thomaskoppelaar\",\n      \"contributions\": [\n        \"question\",\n        \"code\",\n        \"userTesting\"\n      ]\n    },\n    {\n      \"login\": \"MehraAkshay\",\n      \"name\": \"Akshay\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/8671497?v=4\",\n      \"profile\": \"http://www.akshaymehra.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"johnlindquist\",\n      \"name\": \"John Lindquist\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/36073?v=4\",\n      \"profile\": \"http://johnlindquist.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"epicfaace\",\n      \"name\": \"Ashwin Ramaswami\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/1689183?v=4\",\n      \"profile\": \"https://ashwin.run/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Klaudioz\",\n      \"name\": \"Claudio Canales\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/632625?v=4\",\n      \"profile\": \"https://github.com/Klaudioz\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"vitaly-pevgonen\",\n      \"name\": \"vitaly-pevgonen\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/6272738?v=4\",\n      \"profile\": \"https://github.com/vitaly-pevgonen\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"dshemetov\",\n      \"name\": \"Dmitry Shemetov\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/1810426?v=4\",\n      \"profile\": \"https://dshemetov.github.io\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"hooncp\",\n      \"name\": \"hooncp\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/48883554?v=4\",\n      \"profile\": \"https://github.com/hooncp\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"martinlaws\",\n      \"name\": \"Martin Laws\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/13721239?v=4\",\n      \"profile\": \"http://rt-canada.ca\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"sksmith\",\n      \"name\": \"Sean K Smith\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/2085441?v=4\",\n      \"profile\": \"http://seanksmith.me\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"kneely\",\n      \"name\": \"Kevin Neely\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/37545028?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/kevin-neely/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ariefrahmansyah\",\n      \"name\": \"Arief Rahmansyah\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/8122852?v=4\",\n      \"profile\": \"https://ariefrahmansyah.dev\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"vHanda\",\n      \"name\": \"Vishesh Handa\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/426467?v=4\",\n      \"profile\": \"http://vhanda.in\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"HeroicHitesh\",\n      \"name\": \"Hitesh Kumar\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/37622734?v=4\",\n      \"profile\": \"http://www.linkedin.com/in/heroichitesh\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"spencerwooo\",\n      \"name\": \"Spencer Woo\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/32114380?v=4\",\n      \"profile\": \"https://spencerwoo.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ingalless\",\n      \"name\": \"ingalless\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/22981941?v=4\",\n      \"profile\": \"https://ingalless.com\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jmg-duarte\",\n      \"name\": \"José Duarte\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/15343819?v=4\",\n      \"profile\": \"http://jmg-duarte.github.io\",\n      \"contributions\": [\n        \"code\",\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"yenly\",\n      \"name\": \"Yenly\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/6759658?v=4\",\n      \"profile\": \"https://www.yenly.wtf\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"hikerpig\",\n      \"name\": \"hikerpig\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/2259688?v=4\",\n      \"profile\": \"https://www.hikerpig.cn\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Sigfried\",\n      \"name\": \"Sigfried Gold\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/1586931?v=4\",\n      \"profile\": \"http://sigfried.org\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"tristansokol\",\n      \"name\": \"Tristan Sokol\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/867661?v=4\",\n      \"profile\": \"http://www.tristansokol.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"umbrellait-danil-rodin\",\n      \"name\": \"Danil Rodin\",\n      \"avatar_url\": \"https://avatars0.githubusercontent.com/u/49779373?v=4\",\n      \"profile\": \"https://umbrellait.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"scott-joe\",\n      \"name\": \"Scott Williams\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/2026866?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/scottjoewilliams/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Jackiexiao\",\n      \"name\": \"jackiexiao\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/18050469?v=4\",\n      \"profile\": \"https://jackiexiao.github.io/blog\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jbn\",\n      \"name\": \"John B Nelson\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/78835?v=4\",\n      \"profile\": \"https://generativist.substack.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"asifm\",\n      \"name\": \"Asif Mehedi\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/3958387?v=4\",\n      \"profile\": \"https://github.com/asifm\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"litanlitudan\",\n      \"name\": \"Tan Li\",\n      \"avatar_url\": \"https://avatars2.githubusercontent.com/u/4970420?v=4\",\n      \"profile\": \"https://github.com/litanlitudan\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ShaunaGordon\",\n      \"name\": \"Shauna Gordon\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/579361?v=4\",\n      \"profile\": \"http://shaunagordon.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"MCluck90\",\n      \"name\": \"Mike Cluck\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/1753801?v=4\",\n      \"profile\": \"https://mcluck.tech\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"bpugh\",\n      \"name\": \"Brandon Pugh\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/684781?v=4\",\n      \"profile\": \"http://brandonpugh.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"themaxdavitt\",\n      \"name\": \"Max Davitt\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/27709025?v=4\",\n      \"profile\": \"https://max.davitt.me\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"anglinb\",\n      \"name\": \"Brian Anglin\",\n      \"avatar_url\": \"https://avatars3.githubusercontent.com/u/2637602?v=4\",\n      \"profile\": \"http://briananglin.me\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"elswork\",\n      \"name\": \"elswork\",\n      \"avatar_url\": \"https://avatars1.githubusercontent.com/u/1455507?v=4\",\n      \"profile\": \"http://deft.work\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"leonhfr\",\n      \"name\": \"léon h\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/19996318?v=4\",\n      \"profile\": \"http://leonh.fr/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"njnygaard\",\n      \"name\": \"Nikhil Nygaard\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4606342?v=4\",\n      \"profile\": \"https://nygaard.site\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"nitwit-se\",\n      \"name\": \"Mark Dixon\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1382124?v=4\",\n      \"profile\": \"http://www.nitwit.se\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"joeltjames\",\n      \"name\": \"Joel James\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3732400?v=4\",\n      \"profile\": \"https://github.com/joeltjames\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"ryo33\",\n      \"name\": \"Hashiguchi Ryo\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8780513?v=4\",\n      \"profile\": \"https://www.ryo33.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"movermeyer\",\n      \"name\": \"Michael Overmeyer\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1459385?v=4\",\n      \"profile\": \"https://movermeyer.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"derrickqin\",\n      \"name\": \"Derrick Qin\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3038111?v=4\",\n      \"profile\": \"https://github.com/derrickqin\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"zomars\",\n      \"name\": \"Omar López\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/3504472?v=4\",\n      \"profile\": \"https://www.linkedin.com/in/zomars/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"RobinKing\",\n      \"name\": \"Robin King\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1583193?v=4\",\n      \"profile\": \"http://robincn.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"dheepakg\",\n      \"name\": \"Dheepak \",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4730170?v=4\",\n      \"profile\": \"http://twitter.com/deegovee\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"daniel-vera-g\",\n      \"name\": \"Daniel VG\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/28257108?v=4\",\n      \"profile\": \"https://github.com/daniel-vera-g\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Barabazs\",\n      \"name\": \"Barabas\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/31799121?v=4\",\n      \"profile\": \"https://github.com/Barabazs\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"EngincanV\",\n      \"name\": \"Engincan VESKE\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/43685404?v=4\",\n      \"profile\": \"http://enginveske@gmail.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"pderaaij\",\n      \"name\": \"Paul de Raaij\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/495374?v=4\",\n      \"profile\": \"http://www.paulderaaij.nl\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"bronson\",\n      \"name\": \"Scott Bronson\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1776?v=4\",\n      \"profile\": \"https://github.com/bronson\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"rafo\",\n      \"name\": \"Rafael Riedel\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/41793?v=4\",\n      \"profile\": \"http://rafaelriedel.de\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Pearcekieser\",\n      \"name\": \"Pearcekieser\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/5055971?v=4\",\n      \"profile\": \"https://github.com/Pearcekieser\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"theowenyoung\",\n      \"name\": \"Owen Young\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/62473795?v=4\",\n      \"profile\": \"https://github.com/theowenyoung\",\n      \"contributions\": [\n        \"doc\",\n        \"content\"\n      ]\n    },\n    {\n      \"login\": \"ksprashu\",\n      \"name\": \"Prashanth Subrahmanyam\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/476729?v=4\",\n      \"profile\": \"http://www.prashu.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"JonasSprenger\",\n      \"name\": \"Jonas SPRENGER\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/25108895?v=4\",\n      \"profile\": \"https://github.com/JonasSprenger\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Laptop765\",\n      \"name\": \"Paul\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1468359?v=4\",\n      \"profile\": \"https://github.com/Laptop765\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"eltociear\",\n      \"name\": \"Ikko Ashimine\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/22633385?v=4\",\n      \"profile\": \"https://bandism.net/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"memeplex\",\n      \"name\": \"memeplex\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/2845433?v=4\",\n      \"profile\": \"https://github.com/memeplex\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"AndreiD049\",\n      \"name\": \"AndreiD049\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/52671223?v=4\",\n      \"profile\": \"https://github.com/AndreiD049\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"iam-yan\",\n      \"name\": \"Yan\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/48427014?v=4\",\n      \"profile\": \"https://github.com/iam-yan\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jimt\",\n      \"name\": \"Jim Tittsler\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/180326?v=4\",\n      \"profile\": \"https://WikiEducator.org/User:JimTittsler\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"MalcolmMielle\",\n      \"name\": \"Malcolm Mielle\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4457840?v=4\",\n      \"profile\": \"http://malcolmmielle.wordpress.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"veesar\",\n      \"name\": \"Veesar\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/74916913?v=4\",\n      \"profile\": \"https://snippets.page/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"bentongxyz\",\n      \"name\": \"bentongxyz\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/60358804?v=4\",\n      \"profile\": \"https://github.com/bentongxyz\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"techCarpenter\",\n      \"name\": \"Brian DeVries\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/42778030?v=4\",\n      \"profile\": \"https://brianjdevries.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"cliffordfajardo\",\n      \"name\": \"Clifford Fajardo \",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6743796?v=4\",\n      \"profile\": \"http://Cliffordfajardo.com\",\n      \"contributions\": [\n        \"tool\"\n      ]\n    },\n    {\n      \"login\": \"chrisUsick\",\n      \"name\": \"Chris Usick\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6589365?v=4\",\n      \"profile\": \"http://cu-dev.ca\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"josephdecock\",\n      \"name\": \"Joe DeCock\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1145533?v=4\",\n      \"profile\": \"https://github.com/josephdecock\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"drewtyler\",\n      \"name\": \"Drew Tyler\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/5640816?v=4\",\n      \"profile\": \"http://www.drewtyler.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Lauviah0622\",\n      \"name\": \"Lauviah0622\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/43416399?v=4\",\n      \"profile\": \"https://github.com/Lauviah0622\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"joshdover\",\n      \"name\": \"Josh Dover\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1813008?v=4\",\n      \"profile\": \"https://www.elastic.co/elastic-agent\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"phelma\",\n      \"name\": \"Phil Helm\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/4057948?v=4\",\n      \"profile\": \"http://phelm.co.uk\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"lingyv-li\",\n      \"name\": \"Larry Li\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8937944?v=4\",\n      \"profile\": \"https://github.com/lingyv-li\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"infogulch\",\n      \"name\": \"Joe Taber\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/133882?v=4\",\n      \"profile\": \"https://github.com/infogulch\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"readingsnail\",\n      \"name\": \"Woosuk Park\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1904967?v=4\",\n      \"profile\": \"https://www.readingsnail.pe.kr\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"dmurph\",\n      \"name\": \"Daniel Murphy\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/294026?v=4\",\n      \"profile\": \"http://www.dmurph.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Dominic-DallOsto\",\n      \"name\": \"Dominic D\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/26859884?v=4\",\n      \"profile\": \"https://github.com/Dominic-DallOsto\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"elgirafo\",\n      \"name\": \"luca\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/80516439?v=4\",\n      \"profile\": \"http://elgirafo.xyz\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Lloyd-Jackman-UKPL\",\n      \"name\": \"Lloyd Jackman\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/55206370?v=4\",\n      \"profile\": \"https://github.com/Lloyd-Jackman-UKPL\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"sn3akiwhizper\",\n      \"name\": \"sn3akiwhizper\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/102705294?v=4\",\n      \"profile\": \"http://sn3akiwhizper.github.io\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"jonathanpberger\",\n      \"name\": \"jonathan berger\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/41085?v=4\",\n      \"profile\": \"http://jonathanpberger.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"badsketch\",\n      \"name\": \"Daniel Wang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/8953212?v=4\",\n      \"profile\": \"https://github.com/badsketch\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"tlylt\",\n      \"name\": \"Liu YongLiang\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/41845017?v=4\",\n      \"profile\": \"http://yongliangliu.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Skakerman\",\n      \"name\": \"Scott Akerman\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/15224439?v=4\",\n      \"profile\": \"http://scottakerman.com\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"jimgraham\",\n      \"name\": \"Jim Graham\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/430293?v=4\",\n      \"profile\": \"http://www.jim-graham.net/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"hezhizhen\",\n      \"name\": \"Zhizhen He\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/7611700?v=4\",\n      \"profile\": \"https://t.me/littlepoint\",\n      \"contributions\": [\n        \"tool\"\n      ]\n    },\n    {\n      \"login\": \"tcheneau\",\n      \"name\": \"Tony Cheneau\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/952059?v=4\",\n      \"profile\": \"https://amnesiak.org/me\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"nicholas-l\",\n      \"name\": \"Nicholas Latham\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/12977174?v=4\",\n      \"profile\": \"https://github.com/nicholas-l\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"thara\",\n      \"name\": \"Tomochika Hara\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/1532891?v=4\",\n      \"profile\": \"https://thara.dev\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"dcarosone\",\n      \"name\": \"Daniel Carosone\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/11495017?v=4\",\n      \"profile\": \"https://github.com/dcarosone\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"MABruni\",\n      \"name\": \"Miguel Angel Bruni Montero\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/100445384?v=4\",\n      \"profile\": \"https://github.com/MABruni\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Walshkev\",\n      \"name\": \"Kevin Walsh \",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/77123083?v=4\",\n      \"profile\": \"https://github.com/Walshkev\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"hereistheusername\",\n      \"name\": \"Xinglan Liu\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/33437051?v=4\",\n      \"profile\": \"http://hereistheusername.github.io/\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"Hegghammer\",\n      \"name\": \"Thomas Hegghammer\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/64712218?v=4\",\n      \"profile\": \"http://www.hegghammer.com\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"PiotrAleksander\",\n      \"name\": \"Piotr Mrzygłosz\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/6314591?v=4\",\n      \"profile\": \"https://github.com/PiotrAleksander\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"markschaver\",\n      \"name\": \"Mark Schaver\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/7584?v=4\",\n      \"profile\": \"http://schaver.com/\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"n8layman\",\n      \"name\": \"Nathan Layman\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/25353944?v=4\",\n      \"profile\": \"https://github.com/n8layman\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"emmanuel-ferdman\",\n      \"name\": \"Emmanuel Ferdman\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/35470921?v=4\",\n      \"profile\": \"https://github.com/emmanuel-ferdman\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"Tenormis\",\n      \"name\": \"Tenormis\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/61572102?v=4\",\n      \"profile\": \"https://github.com/Tenormis\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"djplaner\",\n      \"name\": \"David Jones\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/225052?v=4\",\n      \"profile\": \"http://djon.es/blog\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"s-jacob-powell\",\n      \"name\": \"S. Jacob Powell\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/109111499?v=4\",\n      \"profile\": \"https://github.com/s-jacob-powell\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"figdavi\",\n      \"name\": \"Davi Figueiredo\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/99026991?v=4\",\n      \"profile\": \"https://github.com/figdavi\",\n      \"contributions\": [\n        \"doc\"\n      ]\n    },\n    {\n      \"login\": \"ChThH\",\n      \"name\": \"CT Hall\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/9499483?v=4\",\n      \"profile\": \"https://github.com/ChThH\",\n      \"contributions\": [\n        \"code\"\n      ]\n    },\n    {\n      \"login\": \"meestahp\",\n      \"name\": \"meestahp\",\n      \"avatar_url\": \"https://avatars.githubusercontent.com/u/177708514?v=4\",\n      \"profile\": \"https://github.com/meestahp\",\n      \"contributions\": [\n        \"code\"\n      ]\n    }\n  ],\n  \"contributorsPerLine\": 7,\n  \"skipCi\": true,\n  \"commitType\": \"docs\"\n}\n"
  },
  {
    "path": ".claude/commands/prepare-pr.md",
    "content": "# Prepare PR Command\n\nAnalyze the current branch changes and generate:\n\n- a PR title\n- a PR description\n- considerations for the developer before pushing the PR\n\nOutput the title and description ready to paste into GitHub.\n\n## PR TITLE\n\nUse format: `type(scope): description`\n\n- type: feat/fix/refactor/perf/docs/chore\n- Keep under 72 characters\n- Be specific but brief\n\n## PR DESCRIPTION\n\nIt should have these sections (use a paragraph per section, no need to title them).\nCONSTRAINTS:\n\n- 100-200 words total\n- No file names or \"updated X file\" statements\n- Active voice\n- No filler or pleasantries\n- Focus on WHAT and WHY, not HOW\n\n### What Changed\n\nList 2-4 changes grouped by DOMAIN, not files. Focus on:\n\n- User-facing changes\n- Architectural shifts\n- API changes\n  Skip trivial updates (formatting, minor refactors).\n\n### Why\n\nOne sentence explaining motivation (skip if obvious from title).\n\n### Critical Notes\n\nONLY include if relevant:\n\n- Breaking changes\n- Performance impact\n- Security implications\n- New dependencies\n- Required config/env changes\n- Database migrations\n\nIf no critical notes exist, omit this section.\n\n## Considerations\n\nRun the `yarn lint` command and report any failures.\nAlso analize the changeset, and act as a PR reviewer to provide comments about the changes.\n"
  },
  {
    "path": ".claude/commands/research-issue.md",
    "content": "# Research Issue Command\n\nResearch a GitHub issue by analyzing the issue details and codebase to generate a comprehensive task analysis file.\n\n## Usage\n\n```\n/research-issue <issue-number>\n```\n\n## Parameters\n\n- `issue-number` (required): The GitHub issue number to research\n\n## Description\n\nThis command performs comprehensive research on a GitHub issue by:\n\n1. **Fetching Issue Details**: Uses `gh issue view` to get issue title, description, labels, comments, and related information\n2. **Codebase Analysis**: Searches the codebase for relevant files, patterns, and components mentioned in the issue\n3. **Root Cause Analysis**: Identifies possible technical causes based on the issue description and codebase findings\n4. **Solution Planning**: Proposes two solution approaches ranked by preference\n5. **Documentation**: Creates a structured task file in `.agent/tasks/<issue-id>-<sanitized-title>.md`\n\nIf there is already a `.agent/tasks/<issue-id>-<sanitized-title>.md` file, use it for context and update it accordingly.\nIf at any time during these steps you need clarifying information from me, please ask.\n\n## Output Format\n\nCreates a markdown file with:\n\n- Issue summary with link and key details\n- Research findings from codebase analysis\n- Identified possible root causes\n- Two ranked solution approaches with pros/cons\n- Technical considerations and dependencies\n\n## Examples\n\n```\n/research-issue 1234\n/research-issue 567\n```\n\n## Implementation\n\nThe command will:\n\n1. Validate the issue number and check if it exists\n2. Fetch issue details using GitHub CLI\n3. Search codebase for relevant patterns, files, and components\n4. Analyze findings to identify root causes\n5. Generate structured markdown file with research results\n6. Save to `.agent/tasks/` directory with standardized naming\n\n## Error Handling\n\n- Invalid issue numbers\n- GitHub CLI authentication issues\n- Network connectivity problems\n- File system write permissions\n"
  },
  {
    "path": ".devcontainer/devcontainer.json",
    "content": "{\n  \"name\": \"Foam Dev Container\",\n  \"image\": \"mcr.microsoft.com/devcontainers/typescript-node:0-18\",\n  \"postCreateCommand\": \"yarn install\",\n  \"customizations\": {\n    \"vscode\": {\n      \"extensions\": [\"dbaeumer.vscode-eslint\", \"esbenp.prettier-vscode\"]\n    }\n  }\n}\n"
  },
  {
    "path": ".editorconfig",
    "content": "indent_style = space\nindent_size = 2"
  },
  {
    "path": ".eslintrc.json",
    "content": "{\n  \"root\": true,\n  \"parser\": \"@typescript-eslint/parser\",\n  \"parserOptions\": {\n    \"ecmaVersion\": 6,\n    \"sourceType\": \"module\"\n  },\n  \"env\": { \"node\": true, \"es6\": true },\n  \"plugins\": [\"jest\"],\n  \"extends\": [\n    \"eslint:recommended\",\n    \"plugin:import/typescript\",\n    \"plugin:jest/recommended\"\n  ],\n  \"rules\": {\n    \"no-redeclare\": \"off\",\n    \"no-unused-vars\": \"off\",\n    \"no-use-before-define\": \"off\",\n    \"@typescript-eslint/no-use-before-define\": \"off\",\n    \"@typescript-eslint/no-explicit-any\": \"off\",\n    \"@typescript-eslint/no-non-null-assertion\": \"off\",\n    \"@typescript-eslint/explicit-function-return-type\": \"off\",\n    \"@typescript-eslint/interface-name-prefix\": \"off\",\n    \"@typescript-eslint/no-unused-vars\": \"warn\",\n    \"import/no-extraneous-dependencies\": [\n      \"error\",\n      {\n        \"devDependencies\": [\"**/src/test/**\", \"**/src/**/*{test,spec}.ts\"]\n      }\n    ]\n  },\n  \"overrides\": [\n    {\n      // Restrict usage of fs module outside tests to keep foam compatible with the browser\n      \"files\": [\"**/src/**\"],\n      \"excludedFiles\": [\"**/src/test/**\", \"**/src/**/*{test,spec}.ts\"],\n      \"rules\": {\n        \"no-restricted-imports\": [\n          \"error\",\n          {\n            \"name\": \"fs\",\n            \"message\": \"Extension code must not rely Node.js filesystem, use vscode.workspace.fs instead.\"\n          }\n        ]\n      }\n    }\n  ],\n  \"settings\": {\n    \"import/core-modules\": [\"vscode\"],\n    \"import/parsers\": {\n      \"@typescript-eslint/parser\": [\".ts\", \".tsx\"]\n    },\n    \"import/resolver\": {\n      \"typescript\": {\n        \"alwaysTryTypes\": true\n      }\n    }\n  },\n  \"ignorePatterns\": [\"**/core/common/**\", \"*.js\"],\n  \"reportUnusedDisableDirectives\": true\n}\n"
  },
  {
    "path": ".gitattributes",
    "content": "*.md linguist-detectable=true\n*.css linguist-detectable=false\n*.html linguist-detectable=true"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.yml",
    "content": "name: 'Bug report'\ndescription: Create a report to help us improve\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        Thank you for reporting an issue :pray:.\n\n        This issue tracker is for reporting bugs found in `foam` (https://github.com/foambubble).\n        If you have a question about how to achieve something and are struggling, please post a question\n        inside of either of the following places:\n          - Foam's Discussion's tab: https://github.com/foambubble/foam/discussions\n          - Foam's Discord channel: https://foambubble.github.io/join-discord/g\n          \n\n        Before submitting a new bug/issue, please check the links below to see if there is a solution or question posted there already:\n         - Foam's Issue's tab: https://github.com/foambubble/foam/pulls?q=is%3Apr+is%3Aopen+sort%3Aupdated-desc\n         - Foam's closed issues tab: https://github.com/foambubble/foam/issues?q=is%3Aissue+sort%3Aupdated-desc+is%3Aclosed\n         - Foam's Discussions tab: https://github.com/foambubble/foam/discussions\n\n        The more information you fill in, the better the community can help you.\n  - type: textarea\n    id: description\n    attributes:\n      label: Describe the bug\n      description: Provide a clear and concise description of the challenge you are running into.\n    validations:\n      required: true\n  - type: input\n    id: reproducible_example\n    attributes:\n      label: Small Reproducible Example\n      description: |\n        Note:\n        - Your bug will may get fixed much faster if there is a way we can somehow run your example or code.\n        - To create a shareable example, consider cloning the following Foam Github template: https://github.com/foambubble/foam-template\n        - Please read these tips for providing a minimal example: https://stackoverflow.com/help/mcve.\n      placeholder: |\n        e.g. Link to your github repository containing a small reproducible example that the team can run.\n    validations:\n      required: false\n  - type: textarea\n    id: steps\n    attributes:\n      label: Steps to Reproduce the Bug or Issue\n      description: Describe the steps we have to take to reproduce the behavior.\n      placeholder: |\n        1. Go to '...'\n        2. Click on '....'\n        3. Scroll down to '....'\n        4. See error\n    validations:\n      required: true\n  - type: textarea\n    id: expected\n    attributes:\n      label: Expected behavior\n      description: Provide a clear and concise description of what you expected to happen.\n      placeholder: |\n        As a user, I expected ___ behavior but i am seeing ___\n    validations:\n      required: true\n  - type: textarea\n    id: screenshots_or_videos\n    attributes:\n      label: Screenshots or Videos\n      description: |\n        If applicable, add screenshots or a video to help explain your problem.\n        For more information on the supported file image/file types and the file size limits, please refer\n        to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files\n      placeholder: |\n        You can drag your video or image files inside of this editor ↓\n  - type: input\n    id: os\n    attributes:\n      label: Operating System Version\n      description: What operating system are you using?\n      placeholder: |\n        - OS: [e.g. macOS, Windows, Linux]\n    validations:\n      required: true\n  - type: input\n    id: vscode_version\n    attributes:\n      label: Visual Studio Code Version\n      description: |\n        What version of Visual Studio Code are you using?\n        How to find Visual Studio Code Version: https://code.visualstudio.com/docs/supporting/FAQ#_how-do-i-find-the-version\n    validations:\n      required: true\n  - type: textarea\n    id: additional\n    attributes:\n      label: Additional context\n      description: |\n        Add any other context about the problem here.\n        The Foam log output for VSCode can be found here: https://github.com/foambubble/foam/blob/main/docs/user/tools/foam-logging-in-vscode.md\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "blank_issues_enabled: true\ncontact_links:\n  - name: Question\n    url: https://foambubble.github.io/join-discord/g\n    about: Please ask and answer questions here.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.yml",
    "content": "name: Feature request\ndescription: Suggest an idea for the `Foam` project\nbody:\n  - type: markdown\n    attributes:\n      value: |\n        This issue form is for requesting features only!\n        If you want to report a bug, please use the [bug report](https://github.com/foambubble/foam/issues/new?assignees=&labels=&template=bug_report.yml) form.\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Is your feature request related to a problem? Please describe.\n      description: A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Describe the solution you'd like\n      description: A clear and concise description of what you want to happen.\n      placeholder: |\n        As a user, I expected ___ behavior but ___ ...\n        \n        Ideal Steps I would like to see:\n        1. Go to '...'\n        2. Click on '....'\n        3. ....\n  - type: textarea\n    validations:\n      required: true\n    attributes:\n      label: Describe alternatives you've considered\n      description: A clear and concise description of any alternative solutions or features you've considered.\n  - type: textarea\n    attributes:\n      label: Screenshots or Videos\n      description: |\n        If applicable, add screenshots or a video to help explain your problem.\n        For more information on the supported file image/file types and the file size limits, please refer\n        to the following link: https://docs.github.com/en/github/writing-on-github/working-with-advanced-formatting/attaching-files\n      placeholder: |\n        You can drag your video or image files inside of this editor ↓"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches:\n      - main\n  pull_request:\n    branches:\n      - main\n\njobs:\n  typos-check:\n    name: Spell Check with Typos\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout Actions Repository\n        uses: actions/checkout@v3\n      - name: Check spelling with custom config file\n        uses: crate-ci/typos@v1.14.8\n        with:\n          config: ./typos.toml\n  lint:\n    name: Lint\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@v1\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '18'\n      - name: Restore Dependencies and VS Code test instance\n        uses: actions/cache@v3\n        with:\n          path: |\n            node_modules\n            */*/node_modules\n            packages/foam-vscode/.vscode-test\n          key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock', 'packages/foam-vscode/src/test/run-tests.ts') }}-${{ secrets.CACHE_VERSION }}\n      - name: Install Dependencies\n        run: yarn\n      - name: Check Lint Rules\n        run: yarn lint\n\n  test:\n    name: Build and Test\n    # strategy:\n    #   matrix:\n    #     os: [macos-12, ubuntu-22.04, windows-2022]\n    # runs-on: ${{ matrix.os }}\n    runs-on: ubuntu-22.04\n    # env:\n    #   OS: ${{ matrix.os }}\n    timeout-minutes: 15\n    steps:\n      - uses: actions/checkout@v1\n      - name: Setup Node\n        uses: actions/setup-node@v3\n        with:\n          node-version: '18'\n      - name: Restore Dependencies and VS Code test instance\n        uses: actions/cache@v3\n        with:\n          path: |\n            node_modules\n            */*/node_modules\n            packages/foam-vscode/.vscode-test\n          key: ${{ runner.os }}-${{ hashFiles('**/yarn.lock', 'packages/foam-vscode/src/test/run-tests.ts') }}-${{ secrets.CACHE_VERSION }}\n      - name: Install Dependencies\n        run: yarn\n      - name: Build Packages\n        run: yarn build\n      - name: Run Tests\n        uses: GabrielBB/xvfb-action@v1.4\n        with:\n          run: yarn test\n"
  },
  {
    "path": ".github/workflows/update-docs.yml",
    "content": "name: Update Docs\n\non:\n  push:\n    branches:\n      - main\n    paths:\n      - docs/user/**/*\n      - docs/.vscode/**/*\n      - docs/assets/**/*\n  workflow_dispatch:\n\njobs:\n  update-docs:\n    runs-on: ubuntu-22.04\n    timeout-minutes: 10\n    steps:\n      - uses: actions/checkout@v3\n        with:\n          repository: foambubble/foam-template\n          path: foam-template\n      - uses: actions/checkout@v3\n        with:\n          path: foam\n      - name: Copy and fixup user docs files\n        id: copy\n        run: |\n          rm -r foam-template/docs\n          rm -r foam-template/assets\n          rm -r foam-template/.vscode\n          cp -r foam/docs/user foam-template/docs\n          cp -r foam/docs/assets foam-template/assets\n          cp -r foam/docs/.vscode foam-template/.vscode\n\n          # Strip autogenerated wikileaks references because\n          # they are not an appropriate default user experience.\n          (cd foam-template/docs; find . -type f -name '*.md' -exec sed -i '/\\[\\/\\/begin\\]/,/\\[\\/\\/end\\]/d' {} +)\n\n          # Set the commit message format\n          echo \"message=Docs sync @ $(cd foam; git log --pretty='format:%h %s')\" >> $GITHUB_OUTPUT\n      - uses: peter-evans/create-pull-request@v4\n        with:\n          token: ${{ secrets.FOAM_DOCS_SYNC_TOKEN }}\n          path: foam-template\n          commit-message: ${{ steps.copy.outputs.message }}\n          branch: bot/foam-docs-sync\n          delete-branch: true\n          title: Sync docs from foam\n          body: Copy docs from main foam repo\n"
  },
  {
    "path": ".gitignore",
    "content": "node_modules\n.DS_Store\n.vscode-test/\n.vscode-test-web/\n*.tsbuildinfo\n*.vsix\n*.log\nout\ndist\npackages/foam-vscode/static/dataviz/\npackages/foam-vscode/src/features/panels/dataviz/graph-protocol.ts\ndocs/_site\ndocs/.sass-cache\ndocs/.jekyll-metadata\n.test-workspace\n.agent/tasks\n"
  },
  {
    "path": ".husky/pre-push",
    "content": "npx yarn lint\n"
  },
  {
    "path": ".vscode/launch.json",
    "content": "// A launch configuration that compiles the extension and then opens it inside a new window\n// Use IntelliSense to learn about possible attributes.\n// Hover to view descriptions of existing attributes.\n// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387\n{\n  \"version\": \"0.2.0\",\n  \"configurations\": [\n    {\n      \"name\": \"Debug Jest Tests\",\n      \"type\": \"extensionHost\",\n      \"request\": \"launch\",\n      \"args\": [\n        \"${workspaceFolder}/packages/foam-vscode/.test-workspace\",\n        \"--disable-extensions\",\n        \"--disable-workspace-trust\",\n        \"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode\",\n        \"--extensionTestsPath=${workspaceFolder}/packages/foam-vscode/out/test/suite\"\n      ],\n      \"outFiles\": [\n        \"${workspaceFolder}/packages/foam-vscode/out/**/*.js\"\n      ],\n      \"preLaunchTask\": \"${defaultBuildTask}\"\n    },\n    {\n      \"name\": \"Run VSCode Extension\",\n      \"type\": \"extensionHost\",\n      \"request\": \"launch\",\n      \"runtimeExecutable\": \"${execPath}\",\n      \"args\": [\n        \"--extensionDevelopmentPath=${workspaceFolder}/packages/foam-vscode\"\n      ],\n      \"outFiles\": [\n        \"${workspaceFolder}/packages/foam-vscode/out/**/*.js\"\n      ],\n      \"preLaunchTask\": \"${defaultBuildTask}\"\n    },\n    {\n      \"type\": \"node\",\n      \"name\": \"vscode-jest-tests\",\n      \"request\": \"launch\",\n      \"console\": \"integratedTerminal\",\n      \"internalConsoleOptions\": \"neverOpen\",\n      \"disableOptimisticBPs\": true,\n      \"cwd\": \"${workspaceFolder}/packages/foam-vscode\",\n      \"runtimeExecutable\": \"yarn\",\n      \"args\": [\n        \"jest\",\n        \"--runInBand\",\n        \"--watchAll=false\"\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": ".vscode/settings.json",
    "content": "// Place your settings in this file to overwrite default and user settings.\n{\n  \"files.exclude\": {\n    // set these to true to hide folders with the compiled JS files,\n    \"packages/**/out\": false,\n    \"packages/**/dist\": false\n  },\n  \"search.exclude\": {\n    // set this to false to include compiled JS folders in search results\n    \"packages/**/out\": true,\n    \"packages/**/dist\": true\n  },\n  // Turn off tsc task auto detection since we have the necessary tasks as npm scripts\n  \"typescript.tsc.autoDetect\": \"off\",\n  \"foam.edit.linkReferenceDefinitions\": \"withExtensions\",\n  \"foam.files.ignore\": [\n    \"**/.vscode/**/*\",\n    \"**/_layouts/**/*\",\n    \"**/_site/**/*\",\n    \"**/node_modules/**/*\",\n    \"packages/**/*\"\n  ],\n  \"editor.tabSize\": 2,\n  \"editor.formatOnSave\": true,\n  \"editor.formatOnSaveMode\": \"file\",\n  \"editor.defaultFormatter\": \"esbenp.prettier-vscode\",\n  \"jest.rootPath\": \"packages/foam-vscode\",\n  \"jest.jestCommandLine\": \"yarn test:unit\",\n  \"gitdoc.enabled\": false,\n  \"search.mode\": \"reuseEditor\",\n  \"[typescript]\": {\n    \"editor.defaultFormatter\": \"esbenp.prettier-vscode\"\n  }\n}\n"
  },
  {
    "path": ".vscode/tasks.json",
    "content": "// See https://go.microsoft.com/fwlink/?LinkId=733558\n// for the documentation about the tasks.json format\n{\n  \"version\": \"2.0.0\",\n  \"tasks\": [\n    {\n      \"label\": \"watch: foam-vscode\",\n      \"type\": \"npm\",\n      \"script\": \"watch\",\n      \"problemMatcher\": {\n        \"owner\": \"typescript\",\n        \"fileLocation\": [\"relative\", \"${workspaceFolder}\"],\n        \"pattern\": [\n          {\n            \"regexp\": \"^(.*?)\\\\((\\\\d+),(\\\\d+)\\\\):\\\\s+(.*)$\",\n            \"file\": 1,\n            \"line\": 2,\n            \"column\": 3,\n            \"message\": 4\n          }\n        ],\n        \"background\": {\n          \"activeOnStart\": true,\n          \"beginsPattern\": {\n            \"regexp\": \".*\"\n          },\n          \"endsPattern\": {\n            \"regexp\": \".*\"\n          }\n        }\n      },\n      \"isBackground\": true,\n      \"presentation\": {\n        \"reveal\": \"always\"\n      },\n      \"group\": {\n        \"kind\": \"build\",\n        \"isDefault\": true\n      }\n    },\n    {\n      \"label\": \"test: all packages\",\n      \"type\": \"npm\",\n      \"script\": \"test\",\n      \"problemMatcher\": [],\n      \"group\": {\n        \"kind\": \"test\",\n        \"isDefault\": true\n      }\n    }\n  ]\n}\n"
  },
  {
    "path": ".yarnrc",
    "content": "--ignore-engines true\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# CLAUDE.md\n\nThis file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.\n\n## Collaboration Principles\n\n**Be honest and objective**: Evaluate all suggestions, ideas, and feedback on their technical merits. Don't be overly complimentary or sycophantic. If something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.\n\n## Project overview\n\nFoam is a personal knowledge management and sharing system, built on Visual Studio Code and GitHub. It allows users to organize research, keep re-discoverable notes, write long-form content, and optionally publish it to the web. The main goals are to help users create relationships between thoughts and information, supporting practices like building a \"Second Brain\" or a \"Zettelkasten\". Foam is free, open-source, and extensible, giving users ownership and control over their information. The target audience includes individuals interested in personal knowledge management, note-taking, and content creation, particularly those familiar with VS Code and GitHub.\n\n## Quick Commands\n\nAll the following commands are to be executed from the `packages/foam-vscode` directory\n\n### Development\n\n- `yarn install` - Install dependencies\n- `yarn build` - Build all packages\n- `yarn watch` - Watch mode for development\n- `yarn clean` - Clean build outputs\n- `yarn reset` - Full clean, install, and build\n\n### Testing\n\n- `yarn test` - Run all tests (unit + integration)\n- `yarn test:unit` - Run unit tests (\\*.test.ts files and the .spec.ts files marked a vscode-mock friendly)\n- `yarn test:e2e` - Run only integration tests (\\*.spec.ts files)\n- `yarn lint` - Run linting\n- `yarn test-reset-workspace` to clean test workspace\n\nUnit tests run in Node.js environment using Jest\nIntegration tests require VS Code extension host\nWhen running tests, do not provide additional parameters, they are ignored by the custom runner script. You cannot run just a test, you have to run the whole suite.\n\nUnit tests are named `*.test.ts` and integration tests are `*.spec.ts`. These test files live alongside the code in the `src` directory. An integration test is one that has a direct or indirect dependency on `vscode` module.\nThere is a mock `vscode` module that can be used to run most integration tests without starting VS Code. Tests that can use this mock are start with the line `/* @unit-ready */`.\n\n- If you are interested in a test inside a `*.test.ts` file, run `yarn test:unit` or inside a `*.spec.ts` file that starts with `/* @unit-ready */` run `yarn test:unit`\n- If you are interested in a test inside a `*.spec.ts` file that does not include `/* @unit-ready */` run `yarn test`\n\nWhile in development we mostly want to use `yarn test:unit`.\nWhen multiple tests are failing, look at all of them, but only focus on fixing the first one. Once that is fixed, run the test suite again and repeat the process.\n\nWhen writing tests keep mocking to a bare minimum. Code should be written in a way that is easily testable and if I/O is necessary, it should be done in appropriate temporary directories.\nNever mock anything that is inside `packages/foam-vscode/src/core/`.\n\nUse the utility functions from `test-utils.ts` and `test-utils-vscode.ts` and `test-datastore.ts`.\n\nTo improve readability of the tests, set up the test and tear it down within the test case (as opposed to use other functions like `beforeEach` unless it's much better to do it that way)\n\nNever fix a test by adjusting the expectation if the expectation is correct, test must be fixed by addressing the issue with the code.\n\n## Repository Structure\n\nThis is a monorepo using Yarn workspaces with the main VS Code extension in `packages/foam-vscode/`.\n\n### Key Directories\n\n- `packages/foam-vscode/src/core/` - Platform-agnostic business logic (NO vscode dependencies)\n- `packages/foam-vscode/src/features/` - VS Code-specific features and UI\n- `packages/foam-vscode/src/services/` - service implementations, might have VS Code dependency, but we try keep that to a minimum\n- `packages/foam-vscode/src/test/` - Test utilities and mocks\n- `packages/foam-vscode/webview-ui/graph/` - Graph visualization web component (`@foam/graph`)\n- `docs/` - Documentation and user guides\n\n### Graph Webview (`@foam/graph`)\n\nThe graph webview is a standalone Yarn workspace and publishable web component built with Lit.\n\n- Source lives in `webview-ui/graph/src/`; `static/dataviz/` is **build output** (gitignored), not source\n- `src/protocol.ts` owns the message contract between extension host and webview — the extension imports from `@foam/graph/protocol`\n- The extension's `tsconfig.json` uses `paths` to resolve `@foam/graph/*` to TypeScript source for type checking; esbuild resolves via package exports at bundle time\n\nCommands (run from repo root or `packages/foam-vscode`):\n\n- `yarn workspace @foam/graph build` - Build lib (ESM) and VS Code bundle\n- `yarn workspace @foam/graph build:vscode` - Build VS Code bundle only\n- `yarn workspace @foam/graph build:lib` - Build publishable ESM bundle only\n- `yarn workspace @foam/graph watch` - Watch mode for webview development\n- `yarn workspace @foam/graph test` - Run webview tests (Vitest, not Jest)\n\n### File Naming Patterns\n\nTest files follow `*.test.ts` for unit tests and `*.spec.ts` for integration tests, living alongside the code in `src`. An integration test is one that has a direct or indirect dependency on `vscode` package.\n\n### Important Constraint\n\nCode in `packages/foam-vscode/src/core/` MUST NOT depend on the `vscode` library or any files outside the core directory. This maintains platform independence.\n\n## Architecture Overview\n\n### Core Abstractions\n\n**FoamWorkspace** - Central repository managing all resources (notes, attachments)\n\n- Uses reversed trie for efficient resource lookup\n- Event-driven updates (onDidAdd, onDidUpdate, onDidDelete)\n- Handles identifier resolution for short-form linking\n\n**FoamGraph** - Manages relationship graph between resources\n\n- Tracks links and backlinks between resources\n- Real-time updates when workspace changes\n- Handles placeholder resources for broken links\n\n**ResourceProvider Pattern** - Pluggable architecture for different file types\n\n- `MarkdownProvider` for .md files\n- `AttachmentProvider` for other file types\n- Extensible for future resource types\n\n**DataStore Interface** - Abstract file system operations\n\n- Platform-agnostic file access with configurable filtering\n- Supports both local and remote file systems\n\n### Feature Integration Pattern\n\nFeatures are registered as functions receiving:\n\n```typescript\n(context: ExtensionContext, foamPromise: Promise<Foam>) => void\n```\n\nThis allows features to:\n\n- Register VS Code commands, providers, and event handlers\n- Access the Foam workspace when ready\n- Extend markdown-it for preview rendering\n\n### Testing Conventions\n\n- `*.test.ts` - Unit tests using Jest\n- `*.spec.ts` - Integration tests requiring VS Code extension host\n- Tests live alongside source code in `src/`\n- Test cases should be phrased in terms of aspects of the feature being tested (expected behaviors), as they serve both as validation of the code as well as documentation of what the expected behavior for the code is in different situations. They should include the happy paths and edge cases.\n\n## Development Workflow\n\nWe build production code together. I handle implementation details while you guide architecture and catch complexity early.\nWhen working on an issue, check if a `.agent/tasks/<issue-id>-<sanitized-title>.md` exists. If not, suggest whether we should start by doing a research on it (using the `/research-issue <issue-id>`) command.\nWhenever we work together on a task, feel free to challenge my assumptions and ideas and be critical if useful.\n\n## Core Workflow: Research → Plan → Implement → Validate\n\n**Start every feature with:** \"Let me research the codebase and create a plan before implementing.\"\n\n1. **Research** - Understand existing patterns and architecture\n2. **Plan** - Propose approach and verify with you\n3. **Implement** - Build with tests and error handling\n4. **Validate** - ALWAYS run formatters, linters, and tests after implementation\n\n- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.\n- Let's use pure functions where possible to improve readability and testing.\n\n### Adding New Features\n\n1. Create feature in `src/features/` directory\n2. Register feature in `src/features/index.ts`\n3. Add tests (both unit and integration as needed)\n4. Update configuration in `package.json` if needed\n\n### Working on an issue\n\n1. Get the issue information from github\n2. Define a step by step plan for addressing the issue\n3. Create tests for the feature\n4. **IMPORTANT**: Run the tests to ensure they FAIL before implementing the fix (this validates the test is actually testing what we think it is)\n5. Implement the fix to make the test pass\n6. Run the tests again to verify the fix works\n\n### Core Logic Changes\n\n1. Modify code in `src/core/` (ensure no vscode dependencies)\n2. Add comprehensive unit tests\n3. Update integration tests in features that use the core logic\n\n## Configuration\n\nThe extension uses VS Code's configuration system with the `foam.*` namespace.\nYou can find all the settings in `/packages/foam-vscode/package.json`\n\n## Common Development Tasks\n\n### Extending Core Functionality\n\nWhen adding to `src/core/`:\n\n- Keep platform-agnostic (no vscode imports)\n- Add comprehensive unit tests\n- Consider impact on graph and workspace state\n- Update relevant providers if needed\n\n## Dependencies\n\n- **Runtime**: VS Code API, markdown parsing, file watching\n- **Development**: TypeScript, Jest, ESLint, esbuild\n- **Key Libraries**: remark (markdown parsing), lru-cache, lodash\n- **Graph webview**: Lit (web components), force-graph, d3-force/scale/color, Vitest, happy-dom\n\nThe extension supports both Node.js and browser environments via separate build targets.\n\n## Documentation Guidelines\n\n### User Documentation (`docs/user/`)\n\nDocumentation in `docs/user/` must be written for non-technical users. The goal is to help novice users quickly start using features, not to explain technical implementation details.\n\n**Writing Guidelines:**\n\n- **Target audience**: Assume users are new to Foam and may not be technical\n- **Be concise**: Keep it short and to the point - every sentence must convey useful information\n- **Avoid repetition**: Don't repeat the same concept in different words\n- **Focus on \"how to use\"**: Show users what they can do and how to do it, not how it works internally\n- **Balance brevity with clarity**: Users won't read verbose documentation, but they need enough information to succeed\n- **Use examples**: Show practical use cases rather than abstract descriptions\n- **Start with the most common use case**: Lead with what most users will want to do first\n\n# GitHub CLI Integration\n\nTo interact with the github repo we will be using the `gh` command.\nALWAYS ask before performing a write operation on Github.\n\n## Common Commands for Claude Code Integration\n\n### Issues\n\n```bash\n# List all issues\ngh issue list\n\n# Filter issues by milestone\ngh issue list --milestone \"v1.0.0\"\n\n# Filter issues by assignee\ngh issue list --assignee @me\ngh issue list --assignee username\n\n# Filter issues by label\ngh issue list --label \"bug\"\ngh issue list --label \"enhancement,priority-high\"\n\n# Filter issues by state\ngh issue list --state open\ngh issue list --state closed\ngh issue list --state all\n\n# Combine filters\ngh issue list --milestone \"v1.0.0\" --label \"bug\" --assignee @me\n\n# View specific issue\ngh issue view 123\n\n# Create issue\ngh issue create --title \"Bug fix\" --body \"Description\"\n\n# Add comment to issue\ngh issue comment 123 --body \"Update comment\"\n```\n\n### Pull Requests\n\n```bash\n# List all PRs\ngh pr list\n\n# Filter PRs the same way as for filters (for example, here is by milestone)\ngh pr list --milestone \"v1.0.0\"\n\n# View PR details\ngh pr view 456\n\n# Create PR\ngh pr create --title \"Feature\" --body \"Description\"\n\n# Check out PR locally\ngh pr checkout 456\n\n# Add review comment\ngh pr comment 456 --body \"LGTM\"\n```\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT Licence (MIT)\n\nCopyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nWhere noted, some code uses the following license:\n\nMIT License\n\nCopyright (c) 2015 - present Microsoft Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n"
  },
  {
    "path": "docs/.vscode/custom-tag-style.css",
    "content": ".foam-tag{\n  color:#ffffff;\n  background-color: #000000;\n}\n"
  },
  {
    "path": "docs/.vscode/extensions.json",
    "content": "{\n  // See http://go.microsoft.com/fwlink/?LinkId=827846\n  // for the documentation about the extensions.json format\n  \"recommendations\": [\n    // Foam's own extension\n    \"foam.foam-vscode\",\n\n    // Tons of markdown goodies (lists, tables of content, so much more)\n    \"yzhang.markdown-all-in-one\",\n\n    // Prettier for auto formatting code\n    \"esbenp.prettier-vscode\",\n\n    // Understated grayscale theme (light and dark variants)\n    \"philipbe.theme-gray-matter\"\n  ]\n}\n"
  },
  {
    "path": "docs/.vscode/keybindings.json",
    "content": "// This file does not get automatically applied\n// @TODO: Make it work or document how to copy to user keybindings\n[\n  {\n    \"key\": \"cmd+shift+n\",\n    \"command\": \"foam-vscode.create-note\"\n  }\n]\n"
  },
  {
    "path": "docs/.vscode/settings.json",
    "content": "{\n  \"files.autoSave\": \"onFocusChange\",\n  \"editor.minimap.enabled\": false,\n  \"editor.wrappingIndent\": \"indent\",\n  \"editor.overviewRulerBorder\": false,\n  \"editor.lineHeight\": 24,\n  \"foam.edit.linkReferenceDefinitions\": \"withExtensions\",\n  \"[markdown]\": {\n    \"editor.quickSuggestions\": {\n      \"other\": true,\n      \"comments\": false,\n      \"strings\": false\n    }\n  },\n  \"git.enableSmartCommit\": true,\n  \"git.postCommitCommand\": \"sync\",\n  \"files.exclude\": {\n    \"_site/**\": true\n  },\n  \"files.insertFinalNewline\": true,\n  \"markdown.styles\": [\".vscode/custom-tag-style.css\"]\n}\n"
  },
  {
    "path": "docs/404.md",
    "content": "# Page not found!\n\nWell, that shouldn't have happened!\n\nIf you got here via a link from another document, please file an [issue](https://github.com/foambubble/foam/issues) on our GitHub repo including:\n\n- the page you came from\n- the link you followed\n\nThanks!\n\n-The Foam Team\n"
  },
  {
    "path": "docs/CNAME",
    "content": "foamnotes.com"
  },
  {
    "path": "docs/Gemfile",
    "content": "source \"https://rubygems.org\"\n\n# Hello! This is where you manage which Jekyll version is used to run.\n# When you want to use a different version, change it below, save the\n# file and run `bundle install`. Run Jekyll with `bundle exec`, like so:\n#\n#     bundle exec jekyll serve\n#\n# This will help ensure the proper Jekyll version is running.\n# Happy Jekylling!\n# gem \"jekyll\", \"~> 3.9.0\"\n\n# This is the default theme for new Jekyll sites. You may change this to anything you like.\ngem \"minima\", \"~> 2.0\"\n\n# If you want to use GitHub Pages, remove the \"gem \"jekyll\"\" above and\n# uncomment the line below. To upgrade, run `bundle update github-pages`.\ngem \"github-pages\", \"~> 209\", group: :jekyll_plugins\n\n# If you have any plugins, put them here!\ngroup :jekyll_plugins do\n  gem \"jekyll-feed\", \"~> 0.6\"\nend\n\n# Windows does not include zoneinfo files, so bundle the tzinfo-data gem\n# and associated library.\ninstall_if -> { RUBY_PLATFORM =~ %r!mingw|mswin|java! } do\n  gem \"tzinfo\", \"~> 1.2\"\n  gem \"tzinfo-data\"\nend\n\n# Performance-booster for watching directories on Windows\ngem \"wdm\", \"~> 0.1.0\", :install_if => Gem.win_platform?\n\n# kramdown v2 ships without the gfm parser by default. If you're using\n# kramdown v1, comment out this line.\ngem \"kramdown-parser-gfm\"\n"
  },
  {
    "path": "docs/LICENSE.txt",
    "content": "The MIT Licence (MIT)\n\nCopyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nWhere noted, some code uses the following license:\n\nMIT License\n\nCopyright (c) 2015 - present Microsoft Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n"
  },
  {
    "path": "docs/_config.yml",
    "content": "title: Foam\ngoogle_analytics: \"UA-171027939-1\"\n"
  },
  {
    "path": "docs/_layouts/foam.html",
    "content": "---\nlayout: default\n---\n\n<script type=\"text/javascript\">\n// NOTE: this should be in sync with the settings/usage in the vscode extension\n// atm it's just a wide superset of md extensions to cover a wide range of cases\nvar MD_EXT = ['.md', '.markdown', '.mdx', '.mdown', '.mkdn', '.mkd', '.mdwn', '.mdtxt', '.mdtext', '.text', '.Rmd'];\nfunction normalizeMdLink(link) {\n  var url = new URL(link);\n  var mdFileExt = MD_EXT.find(ext => url.pathname.endsWith(ext));\n  if (mdFileExt) {\n    url.pathname = url.pathname.slice(0, mdFileExt.length * -1);\n  }\n  return url.toString();\n}\n\nwindow.addEventListener('DOMContentLoaded', (event) => {\n  document\n    .querySelectorAll(\".markdown-body a[title]:not([href^=http])\")\n    .forEach((a) => {\n      // filter to only wikilinks\n      var prev = a.previousSibling;\n      var next = a.nextSibling;\n      if (\n        prev instanceof Text && prev.textContent.endsWith('[') &&\n        next instanceof Text && next.textContent.startsWith(']')\n      ) {\n\n        // remove surrounding brackets\n        prev.textContent = prev.textContent.slice(0, -1);\n        next.textContent = next.textContent.slice(1);\n\n        // add CSS list for styling\n        a.classList.add('wikilink');\n\n        // replace page-link with \"Page Title\"...\n        a.innerText = a.title;\n\n        // ...and normalize the links to allow html pages navigation\n        a.href = normalizeMdLink(a.href);\n      }\n    });\n\n  document.querySelectorAll(\".github-only\").forEach((el) => {\n    el.remove();\n  });\n});\n</script>\n\n\n{{ content }}\n"
  },
  {
    "path": "docs/_layouts/home.html",
    "content": "---\nlayout: foam\n---\n\n<script async defer src=\"https://buttons.github.io/buttons.js\"></script>\n\n{{ content }}\n\n<script>\nwindow.addEventListener('DOMContentLoaded', (event) => {\n  var duplicateHeading = document.querySelector(\"h1:not(#foam)\");\n  if (duplicateHeading && duplicateHeading.remove) {\n    duplicateHeading.remove();\n  }\n});\n</script>\n"
  },
  {
    "path": "docs/_layouts/mathjax.html",
    "content": "---\nlayout: foam\n---\n\n{{ content }}\n\n<script src=\"https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML\" type=\"text/javascript\"></script>\n<script type=\"text/x-mathjax-config\">\n    MathJax.Hub.Config({\n        tex2jax: {\n            skipTags: ['script', 'noscript', 'style', 'textarea', 'pre'],\n            inlineMath: [['$','$']]\n        }\n    });\n</script>\n"
  },
  {
    "path": "docs/_layouts/page.html",
    "content": "---\nlayout: foam\n---\n\n{{ content }}\n"
  },
  {
    "path": "docs/assets/css/style.scss",
    "content": "---\n---\n\n@import \"{{ site.theme }}\";\n\na {\n  color: #3300ff;\n}\n\n.markdown-body {\n  max-width: 800px;\n  font-size: 16px;\n}\n\n.markdown-body p {\n  font-size: 16px;\n  line-height: 1.9em;\n  margin-bottom: 1.2em;\n}\n\n.markdown-body li {\n  line-height: 1.9em;\n}\n\ninput.task-list-item-checkbox {\n  margin-right: 4px;\n}\n\nimg[src*=\"demo\"] {\n  border: 1px #eee solid;\n  -webkit-box-shadow: 4px 4px 16px 0px rgba(50, 50, 50, 0.1);\n  -moz-box-shadow: 4px 4px 16px 0px rgba(50, 50, 50, 0.1);\n  box-shadow: 4px 4px 16px 0px rgba(50, 50, 50, 0.1);\n}\n\n@media only screen and (min-width: 1170px) {\n  img[src*=\"demo\"] {\n    max-width: 130%;\n    margin-left: -15%;\n    margin-top: 20px;\n    margin-bottom: 20px;\n  }\n}\n\nh1,\nh2,\nh3,\nh4,\nh5,\nblockquote {\n  font-family: Constantia, \"Lucida Bright\", Lucidabright, \"Lucida Serif\", Lucida,\n    \"DejaVu Serif\", \"Bitstream Vera Serif\", \"Liberation Serif\", Georgia, serif;\n}\n\n.wikilink:before {\n  content: \"[[\";\n  opacity: 0.5;\n}\n\n.wikilink:after {\n  content: \"]]\";\n  opacity: 0.5;\n}\n\n.github-only {\n  display: none;\n}\n\n.announcement {\n  background: #ede7ff;\n  padding: 4px 16px;\n  color: black;\n  border-radius: 4px;\n}\n"
  },
  {
    "path": "docs/dev/about-docs.md",
    "content": "# Developing documentation\n\nThe best way to develop docs for the Foam repo is to directly open the `$foam-repo/docs/` as the root folder in a new vscode window.\nThis automatically configures vscode with the necessary settings enabled (like [[link-reference-definitions]]) to efficiently write this documentation.\n\n## Organization\n\nThe Foam documentation is organized into two areas:\n\n* User docs, located at `$foam-repo/docs/user/*`, which are copied in their entirety into `$foam-template-repo/docs`.\n* Developer docs, located at `$foam-repo/docs/dev/*`\n\nNew user docs should be added to the User docs folder in the main Foam repo, then copied over to the Foam Template repo.\n\n> [[todo]]: Automate this process. Idea: github action to open a PR on any change to `/docs/user`\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[link-reference-definitions]: ../user/features/link-reference-definitions.md \"Link Reference Definitions\"\n[todo]: todo.md \"Todo\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/build-vs-assemble.md",
    "content": "# Build vs Assemble\n\nThe Foam prototype is built by assembling third-party extensions, which seems like a good strategy because\n\n- It supports picking and mixing of tools and workflows\n- Less code to write and maintain\n\nBut there's also a bunch of roadmap items that are hard to implement this way, as the third party plugins don't do exactly what we want them to do (e.g. Markdown All In One is not compatible with [[referencing-notes-by-title]].\n\nOverall, we should strive to build big things from small things. Focused, interoperable modules are better, because they allow users to pick and mix which features work for them. A good example of why this matters is the Markdown All In One extension we rely on: While it provides many of the things we need, a few of its features are incompatible with how I would like to work, and therefore it becomes a limiter of how well I can improve my own workflow.\n\nHowever, there becomes a point where we may benefit from implementing a centralised solution, e.g. a syntax, an extension or perhaps a VSCode language server. As much as possible, we should allow users to operate in a decentralised manner.\n"
  },
  {
    "path": "docs/dev/code-of-conduct.md",
    "content": "---\nredirect_from:\n  - /code-of-conduct\n---\n\n# Code of Conduct\n\nWe follow the [Contributor Covenant](https://www.contributor-covenant.org/) code of conduct.\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, religion, or sexual identity\nand orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the\n  overall community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or\n  advances of any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email\n  address, without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at `riki.code+foam@gmail.com`.\n\nAll complaints will be reviewed and investigated promptly and fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series\nof actions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or\npermanent ban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within\nthe community.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.0, available at\n<https://www.contributor-covenant.org/version/2/0/code_of_conduct.html>.\n\nCommunity Impact Guidelines were inspired by [Mozilla's code of conduct\nenforcement ladder](https://github.com/mozilla/diversity).\n\n[homepage]: https://www.contributor-covenant.org\n\nFor answers to common questions about this code of conduct, see the FAQ at\n<https://www.contributor-covenant.org/faq>. Translations are available at\n<https://www.contributor-covenant.org/translations>.\n"
  },
  {
    "path": "docs/dev/contribution-guide.md",
    "content": "---\ntags: todo, good-first-task\n---\n\n# Contribution Guide\n\nFoam is open to contributions of any kind, including but not limited to code, documentation, ideas, and feedback.\nThis guide aims to help guide new and seasoned contributors getting around the Foam codebase. For a comprehensive guide about contributing to open-source projects in general, [see here](https://blog.robsewell.com/blog/how-to-fork-a-github-repository-and-contribute-to-an-open-source-project/).\n\n## Getting Up To Speed\n\nBefore you start contributing we recommend that you read the following links:\n\n- [[principles]] - This document describes the guiding principles behind Foam.\n- [[code-of-conduct]] - Rules we hope every contributor aims to follow, allowing everyone to participate in our community!\n\nTo get yourself familiar with the codebase you can also browse [this repo](https://app.komment.ai/wiki/github/foambubble/foam)\n\n## Diving In\n\nWe understand that diving in an unfamiliar codebase may seem scary,\nto make it easier for new contributors we provide some resources:\n\nYou can also see [existing issues](https://github.com/foambubble/foam/issues) and help out!\nFinally, the easiest way to help, is to use it and provide feedback by [submitting issues](https://github.com/foambubble/foam/issues/new/choose) or participating in the [Foam Community Discord](https://foambubble.github.io/join-discord/g)!\n\n## Contributing\n\nIf you're interested in contributing, this short guide will help you get things set up locally (assuming [node.js >= v18](https://nodejs.org/) and [yarn](https://yarnpkg.com/) are already installed on your system).\nYou can also use the provided [[devcontainers]] to avoid installing dependencies locally. With the Dev Containers extension installed, open the repository in VS Code and run **Dev Containers: Reopen in Container**.\n\n1. Fork the project to your Github account by clicking the \"Fork\" button on the top right hand corner of the project's [home repository page](https://github.com/foambubble/foam).\n2. Clone your newly forked repo locally:\n\n   `git clone https://github.com/your_username/foam.git`\n\n3. Install the necessary dependencies by running this command from the root of the cloned repository:\n\n   `yarn install`\n\n4. From the repository root, run the command:\n\n   `yarn build`\n\nYou should now be ready to start working!\n\n### Structure of the project\n\nFoam code and documentation live in the monorepo at [foambubble/foam](https://github.com/foambubble/foam/).\n\n- [/docs](https://github.com/foambubble/foam/tree/main/docs): documentation and [[recipes]].\n\nExceptions to the monorepo are:\n\n- The starter template at [foambubble/foam-template](https://github.com/foambubble/)\n- All other [[recommended-extensions]] live in their respective GitHub repos\n\nThis project uses [Yarn workspaces](https://classic.yarnpkg.com/en/docs/workspaces/).\n\nThe monorepo contains two Yarn workspace packages:\n\n- [/packages/foam-vscode](https://github.com/foambubble/foam/tree/main/packages/foam-vscode) - The VS Code extension.\n- [/packages/foam-vscode/webview-ui/graph](https://github.com/foambubble/foam/tree/main/packages/foam-vscode/webview-ui/graph) - The graph visualization web component (`@foam/graph`), published independently.\n\n#### foam-vscode\n\nThe extension's core business logic lives in `src/core/` and must remain platform-agnostic:\n\n1. Nothing in `foam-vscode/src/core` should depend on files outside of this directory.\n2. Code in `foam-vscode/src/core` must NOT depend on the `vscode` library.\n\n#### @foam/graph\n\nThe graph webview is a standalone Lit web component that can be embedded outside of VS Code. Key points:\n\n- `src/protocol.ts` owns the message contract between the extension host and the webview. The extension imports from `@foam/graph/protocol`.\n- `static/dataviz/` is build output (gitignored) — the source lives in `webview-ui/graph/src/`.\n- Build and test commands: `yarn workspace @foam/graph build` / `yarn workspace @foam/graph test`.\n\n### Testing\n\nCode needs to come with tests.\nWe use the following convention in Foam:\n\n- `*.test.ts` are unit tests\n- `*.spec.ts` are integration tests\n\nTests live alongside the code in `src`.\n\n### The VS Code Extension\n\nThis guide assumes you read the previous instructions and you're set up to work on Foam.\n\n1. Now we'll use the launch configuration defined at [`.vscode/launch.json`](https://github.com/foambubble/foam/blob/main/.vscode/launch.json) to start a new extension host of VS Code. Open the \"Run and Debug\" Activity (the icon with the bug on the far left) and select \"Run VSCode Extension\" in the pop-up menu. Now hit F5 or click the green arrow \"play\" button to fire up a new copy of VS Code with your extension installed.\n\n2. In the new extension host of VS Code that launched, open a Foam workspace (e.g. your personal one, or a test-specific one created from [foam-template](https://github.com/foambubble/foam-template)).\n\n3. Test a command to make sure it's working as expected. Open the Command Palette (Ctrl/Cmd + Shift + P) and select \"Foam: Update Markdown Reference List\". If you see no errors, it's good to go!\n\n### Submitting a Pull Request (PR)\n\nAfter you have made your changes to your copy of the project, it is time to try and merge those changes into the public community project.\n\n1. Return to the project's [home repository page](https://github.com/foambubble/foam).\n2. Github should show you an button called \"Compare & pull request\" linking your forked repository to the community repository.\n3. Click that button and confirm that your repository is going to be merged into the community repository. See [this guide](https://blog.robsewell.com/blog/how-to-fork-a-github-repository-and-contribute-to-an-open-source-project/) for more specifics.\n4. Add as many relevant details to the PR message to make it clear to the project maintainers and other members of the community what you have accomplished with your new changes. Link to any issues the changes are related to.\n5. Your PR will then need to be reviewed and accepted by the other members of the community. Any discussion about the changes will occur in your PR thread.\n6. Once reviewed and accept you can complete the merge request!\n7. Finally rest and watch the sun rise on a grateful universe... Or start tackling the other open issues ;)\n\n---\n\nFeel free to modify and submit a PR if this guide is out-of-date or contains errors!\n\n---\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[principles]: ../principles.md \"Principles\"\n[code-of-conduct]: code-of-conduct.md \"Code of Conduct\"\n[devcontainers]: devcontainers.md \"Using Dev Containers\"\n[recipes]: ../user/recipes/recipes.md \"Recipes\"\n[recommended-extensions]: ../user/getting-started/recommended-extensions.md \"Recommended Extensions\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/devcontainers.md",
    "content": "# Using Dev Containers\n\nFoam provides a [devcontainer](https://devcontainer.ai/) configuration to make it easy to contribute without installing Node and Yarn locally.\n\n## Quick start\n\n1. Install [VS Code](https://code.visualstudio.com/) and the [Dev Containers](https://aka.ms/vscode-remote/download/extension) extension.\n2. Open the Foam repository in VS Code.\n3. Run **Dev Containers: Reopen in Container** from the command palette.\n\nThis will build a Docker image with Node 18 and install dependencies using `yarn install`. Once ready you can run the usual build and test commands from the integrated terminal.\n\n\n"
  },
  {
    "path": "docs/dev/foam-file-format.md",
    "content": "# Foam File Format\n\nThis file is an example of a valid Foam file. Essentially it's just a markdown file with a bit of additional support for MediaWiki-style `[[wikilinks]]` and note embeds.\n\nHere are a few specific constraints, mainly because our tooling is a bit fragmented. Most of these should be eventually lifted, and our requirement should just be \"Markdown with `[[wikilinks]]`:\n\n- **The first top level `# Heading` will be used as title for the note.**\n  - If not available, we will use the file name\n- **File name should have extension `.md`**\n  - This is a temporary limitation and will be lifted in future versions.\n  - At least `.mdx` will be supported, but ideally we'll support any file that you can map to `Markdown` language mode in VS Code\n- **In addition to normal Markdown Links syntax you can use `[[MediaWiki]]` links.** See [[wikilinks]] for more details.\n- **You can embed other notes using `![[note]]` syntax.** This supports various modifiers like `content![[note]]` or `full-card![[note]]` to control how content is displayed.\n\n[wikilinks]: ../user/features/wikilinks.md 'Wikilinks'\n"
  },
  {
    "path": "docs/dev/good-first-task.md",
    "content": "# Good First Task\n\nSee the backlinks of this page for good first contribution opportunities.\n\n[[materialized-backlinks]] would help here.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[materialized-backlinks]: proposals/materialized-backlinks.md \"Materialized Backlinks (stub)\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/mdx-by-default.md",
    "content": "# MDX by Default(stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: todo.md \"Todo\"\n[roadmap]: proposals/roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/proposals/foam-core.md",
    "content": "# Foam Core\n\n`foam-core` is a (future) package that powers the core functionality in Foam across all platforms:\n\n- VS Code Extension\n- [[workspace-janitor]]\n- [[cli]]\n- Future initiatives\n  - Visualizations\n    - Tag clouds\n    - Graph\n    - Should we have a package for visualization?\n      - [[build-vs-assemble]]\n      - Not everything needs to live in the Foam repo\n  - Web based UI (Monaco)\n\n`foam-core`'s primary responsibility is to build an API on top of a workspace of markdown files, which allows us to:\n\n- Treat files as a graph, based on links\n  - Can be either [[wikilinks]] or relative `[markdown](links.md)` style\n  - We need to know about the edges (connections) as well as nodes\n    - What link points to what other file, etc.\n    - Needs to have the exact link text, e.g. even if `[[some-page]]` or `[[some-page.md]]` or `[[Some Page]]` point to the same document (`./some-page.md`), we need to know which format was used, so [[link-reference-definitions]] can be generated correctly\n- Treat each file as semi-structured data\n  - Title, headings, lists, paragraphs, images, links, data, code\n  - Also, possible Foam-specific meta stuff, like \"backlinks\" or \"block references\".\n  - This can power advanced search features (e.g. showing entire context of paragraph in back links, find all documents)\n\nIdeally, `foam-core` will be generic enough that in can be used by third parties to build their own tools that operate on markdown directories.\n\n## Technical requirements\n\n- The graph should be relatively inexpensive to compute (for running in CI, mobile etc)\n  - If necessary, we can implement caching inside a dot folder, but ideally not\n  - In memory cache already exist, can prime from disk if needed\n- The graph should be mutable (or immutable but easy to deep clone) so that persistent processes (VS Code) can cheaply update it when\n  - Document content changes\n    - Links update -> Graph change\n    - Heading changes -> Metadata change\n    - Anything in the document changes, update AST for individual note\n  - Files are added\n  - Files are removed\n  - Files are renamed\n  - Aliasing, call the same thing by multiple names\n    - Doesn't exist yet, should we support?\n- The graph should be observable (EventEmitter?) so changes can be applied\n  - EventEmitter w/ cross platform dependency\n  - Wonka (staltz's callbag)\n  - Can be a long term goal\n    - Short term fix: Just run the build fully on every change\n- Ideally, the it should accept file/structure changes from the outside from some sort of event source, so we can decouple source of updates (VS Code Workspace events, file system events...)\n  - If this gets complicated, we can delay this for now\n- We should not take on platform-specific dependencies\n  - Should work in any JS environment\n- Written in TypeScript (preferably tsdx)\n- Published to NPM\n\n## Use cases\n\nHere are some example use cases that the core should support. They don't need to be built into the core, but may help us design correct solutions:\n\n- Adding and editing page content\n  - [[materialized-backlinks]]\n  - [[link-reference-definitions]] for [[wikilinks]]\n  - [Frontmatter](https://jekyllrb.com/docs/front-matter/)\n- Finding all documents with `#tag`\n- Finding all documents with instances of `[[link]]`\n- Visualizations\n- Full text search\n\n  - Or, if search is too expensive/complex, when given a list of file names and line/column positions from VS Code search API, can return the document context (e.g. full paragraph, preceding/following line etc)\n\n## Collaboration\n\n- This week\n  - List of things to work in order\n  - Provide more vision on future state\n  - Write about working and collaboration philosophy\n  - [[todo]] Prioritise roadmap\n- Week of July 13\n  - Jani is available full time to work on this\n  - Janne: Write proposals, maybe more\n  - Riccardo: Available\n\n## Configuration management\n\n- Other tools may not be able to use vscode\n- [[todo]] Discuss with Janne and Riccardo\n\n## Feature comparison\n\nUseful for knowing what needs to be supported. See [[feature-comparison]].\n\n## Meeting notes\n\n- [[foam-core-2020-07-11]]\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[workspace-janitor]: ../../user/tools/workspace-janitor.md \"Janitor\"\n[cli]: ../../user/tools/cli.md \"Command Line Interface\"\n[build-vs-assemble]: ../build-vs-assemble.md \"Build vs Assemble\"\n[wikilinks]: ../../user/features/wikilinks.md \"Wikilinks\"\n[link-reference-definitions]: ../../user/features/link-reference-definitions.md \"Link Reference Definitions\"\n[materialized-backlinks]: materialized-backlinks.md \"Materialized Backlinks (stub)\"\n[todo]: ../todo.md \"Todo\"\n[foam-core-2020-07-11]: ../meeting-notes/foam-core-2020-07-11.md \"Foam Core 2020-07-11\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/proposals/inclusion-of-notes.md",
    "content": "# Inclusion of notes Proposal <!-- omit in TOC -->\n\nCurrently it is not possible within Foam to include other notes into a note. Next to including a full note it could be interesting to add functionalities that allow for greater flexibility. This proposal discusses some functionalities around inclusion of notes.\n\n**IMPORTANT: This design is merely a proposal of a design that could be implemented. It DOES NOT represent a commitment by `Foam` developers to implement the features outlined in this document. This document is merely a mechanism to facilitate discussion of a possible future direction for `Foam`.**\n\n- [Introduction](#introduction)\n- [New features](#new-features)\n  - [Including a note](#including-a-note)\n  - [Include a section of a note](#include-a-section-of-a-note)\n  - [Include an attribute of a file (note property or frontmatter)](#include-an-attribute-of-a-file-note-property-or-frontmatter)\n\n## Introduction\n\nInitial work and thought on including a note was ignited by issue [#652](https://github.com/foambubble/foam/issues/652). Requested by a user was a likewise functionality as offered in Obsidian. This was simply the ability to include a note.\n\nWhilst researching digital gardening for my own setup, I came across an in-depth overview by [Maggie Appleton](https://maggieappleton.com/roam-garden). Showing examples of her personal Roam Research I see valuable possibilities to connect more information, if we would add additional functionalities to the possibility of including a note. This proposal displays these possible functionalities and markup.\n\n## New features\n\n### Including a note\n\nThe minimal functionality is the ability to fully include a note. Markup used in Obsidian for this is `![[wikilink]]`. For Foam I would suggest to follow this syntax. Benefits being:\n\n- Adds minimal amount of knowledge required as syntax is based on the syntax of creating a wikilink.\n- Makes the auto-complete work ouf-of-the-box, without any additional code and listeners required.\n\n**Important**. A risk exists that a loop of including the same notes arises. E.g. Note A includes note B which includes note A. This needs to be prevented by the implementation and made visible to the user.\n\n### Include a section of a note\n\nIt could be interesting to only include a section of a note instead of the entire note. In order to do so the user should be able to use the following syntax:\n\n`![[wikilink#section-b]]`\n\nAs a result it will include the section title + section content until the next section *or* end of file.\n\n### Include an attribute of a file (note property or frontmatter)\n\nAs a user I could be interested in collecting the value of any given property for a note. For example, I might want to include the tags as defined in the frontmatter of note A. This should be possible via the syntax:\n\n`![[wikilink:<property>]]`\n\nThe property value should be looked up by foam defined properties, e.g. title, **or** any property defined in the frontmatter of a note.\n\nSo, the example of including the tags of a note should be:\n\n`![[wikilink:tags]]`\n"
  },
  {
    "path": "docs/dev/proposals/link-reference-definition-improvements.md",
    "content": "# Link Reference Definition Improvements\n\n## Current Problems\n\n### File-by-file Insertion\n\nFor the time being, if you want to get [[wikilinks]] into all files within the workspace, you'll need to generate the link reference definitions yourself file-by-file (with the assistance of Foam).\n\n### Wikilinks don't work on GitHub\n\n> **TL;DR;** [workaround](#workaround) in the end of the chapter.\n\nIf you click any of the wikilinks on GitHub web UI (such as the `README.md` of a project), you'll notice that the links break with a 404 error.\n\nAt the time of writing (June 28 2020) this is a known, but unsolved error. To understand why this is the case, we need to understand what we are trading off.\n\nSo, why don't they work on GitHub?\n\nThe three components of [[link-reference-definitions]] are link label, link destination and Link Title.\n\nThe issue is the middle **link destination** component. It's configured to point to the file name **without file extension**, i.e. \"file-name\" instead of \"file-name.md\". This is to make the GitHub Pages rendering work, because if we generated the links to `file-name.md`, the links would point to the raw markdown files instead of their generated HTML versions.\n\n| Environment      | `file-name` | `file-name.md` |\n| ---------------- | ----------- | -------------- |\n| **VS Code**      | Works       | Works          |\n| **GitHub pages** | Works       | Breaks         |\n| **GitHub UI**    | Breaks      | Works          |\n\nSo as you can see, we've prioritised GitHub Pages over GitHub Web UI for the time being.\n\nIdeally, we'd like a solution that works with both, but it's not defined yet (see [[link-reference-definitions]] for more details)\n\n#### Workaround\n\nFor the time being, you can use relative `[markdown links](markdown-link.md)` syntax.\n\n**Pros:**\n\n- This will work on all platforms.\n\n**Cons:**\n\n- It will break the Markdown Notes [[backlinking]] support\n- Less convenient to write\n\n### Finding certain words clutter the VS Code search results\n\nSince link reference definitions have `[//begin]` and `[//end]` guards with explanatory text that use certain words, these words (like \"generate\") appear in VS Code search results if you happen to search matching strings from the workspace.\n\n## Improvement Proposal\n\nProblem space in essence:\n\n- During edit-time (when modifying the markdown files in an editor)\n  - link reference definitions are needed if user uses editor extensions that don't understand wikilinks\n  - link reference definitions may be annoying since they\n    - add content to files that the user hasn't typed in by themselves\n    - get out of date if user uses a tool that doesn't autogenerate them\n    - may clutter the search results\n- During build-time (when converting markdown to html for publishing purposes)\n  - link reference definitions are needed, if the files are published via such tools (or to such platforms) that don't understand wikilinks\n  - link reference definitions might have to be in different formats depending on the publish target (e.g. GitHub pages vs GitHub UI)\n\nThe potential solution:\n\n- For edit-time\n  - Make edit-time link reference definition generation optional via user settings. They should be on by default, and generating valid markdown links with a relative path to a `.md` file.\n  - Make format of the link reference definition configurable (whether to include '.md' or not)\n  - Out of recommended extensions, currently only \"markdown links\" doesn't support them (?). However even its [code](https://github.com/tchayen/markdown-links/blob/main/src/parsing.ts#L25) seems to include wikilink parser, so it might just be a bug?\n- For build-time\n\n  - To satisfy mutually incompatible constraints between GitHub UI, VSCode UI, and GitHub Pages, we should add a pre-processing/build step for pushing to GitHub Pages.\n    - This would be a GitHub action (or a local script, ran via foam-cli) that outputs publish-friendly markdown format for static site generators and other publishing tools\n    - This build step should be pluggable, so that other transformations could be ran during it\n  - Have publish targets defined in settings, that support both turning the link reference definitions on/off and defining their format (.md or not). Example draft (including also edit-time aspect):\n\n    ```typescript\n    // settings json\n    // see enumerations below for explanations on values\n    {\n      \"foam\": {\n        \"publish\": [\n          {\n            \"name\": \"Gitlab Mirror\",     // name of the publish target\n            \"linkTranspilation\": \"Off\",\n            \"linkReferenceDefinitions\": \"withExtensions\"\n          },\n          {\n            \"name\": \"GitHub Pages\",\n            \"linkTranspilation\": \"Off\",\n            \"linkReferenceDefinitions\": \"withoutExtensions\"\n          },\n          {\n            \"name\": \"Blog\",\n            \"linkTranspilation\": \"Off\",\n            \"linkReferenceDefinitions\": \"Off\"\n          },\n          {\n            \"name\": \"My Amazing PDF book\",\n            \"linkTranspilation\": \"WikiLinksToMarkdown\"\n          }\n        ],\n        \"edit\": {\n          \"linkReferenceDefinitions\": \"Off\"\n        }\n      }\n    }\n\n    // Defines if and how links in markdown files are somehow converted (in-place) during build time\n    // Note that this enumeration is not valid edit-time, since we (probably) don't want to change text like this while user is editing it\n    enum LinkTranspilation {\n      Off,                   // links are not transpiled\n      WikiLinksToMarkdown,   // links using wiki-format [[link]] are converted to normal md links: [link](./some/file.md)\n                             // if this is set, not link reference definitions are generated (not needed)\n    }\n\n    // Defines if and how link reference definition section is generated\n    enum LinkReferenceDefinitions {\n      Off,               // link reference definitions are not generated\n      WithExtensions,    // link reference definitions contain .md (or similar) file extensions\n      WithoutExtensions  // link reference definitions do not contain file extensions\n    }\n\n    ```\n\n  - With Foam repo, just use edit-time link reference definitions with '.md' extension - this makes the links work in the GitHub UI\n  - Have publish target defined for GitHub pages, that doesn't use '.md' extension, but still has the link reference definitions. Generate the output into gh-pages branch (or separate repo) with automation.\n    - This naturally requires first removing the existing link reference definitions during the build\n\n- Other\n  - To clean up the search results, remove link reference definition section guards (assuming that these are not defined by the markdown spec). Use unifiedjs parse trees to identify if there's missing (or surplus) definitions (check if they are identified properly by the library), and just add the needed definitions to the bottom of the file (without guards) AND remove them if they are not needed (anywhere from the file).\n\nNote that the proposal above supports both (build-time) inline transpilation of wikilinks as well as creation reference definitions. Depending on the direction of Foam, also only one of them could be selected. In that case the other could be implemented at later point of time.\n\nUI-wise, the publish targets could be picked in some similar fashion as the run/debug targets in vscode by implementing a separate panel, or maybe through command execution (CTRL+SHIFT+P) - not yet defined at this point.\n\n## Links\n\n- [tracking issue on GitHub](https://github.com/foambubble/foam/issues/16)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[wikilinks]: ../../user/features/wikilinks.md \"Wikilinks\"\n[link-reference-definitions]: ../../user/features/link-reference-definitions.md \"Link Reference Definitions\"\n[backlinking]: ../../user/features/backlinking.md \"Backlinking\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/proposals/materialized-backlinks.md",
    "content": "# Materialized Backlinks (stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n## Notes\n\nThe idea would be to automatically generate lists of backlinks (and optionally, also forward links) into the bottom of every markdown document to\n\n- Make every link two-way navigable in published sites\n- Make Foam notes more portable to different apps and long-term storage\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: ../todo.md \"Todo\"\n[roadmap]: roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/proposals/roadmap.md",
    "content": "# Roadmap\n\nSome of these items can be achieved by combining existing tools, but others may require us to build bespoke software solutions. See [[build-vs-assemble]] to understand trade-offs between these approaches. If a feature can be implemented by contributing to [[recipes]], it should.\n\n## In progress\n\nItems that are already being worked on. Roadmap items in this stage should have an owner.\n\n## High priority\n\nItems we plan on working next. Items in this stage don't need to have an owner, but before we start working on them should have enough specification that they can be picked up and worked on without having to seek consensus.\n\nIf you want to pick up work in this category, you should have a plan on how long the implementation will approximately take so we don't block progress by sitting on high priority issues.\n\n## Backlog\n\nEverything else, categorised into themes. Just because something is on this list doesn't mean it'll get done. If you're interested in working on items in this category, check the [[contribution-guide]] for how to get started.\n\nIf a roadmap item is a stub, **consider** opening a [GitHub issue](https://github.com/foambubble/foam/issues) to start a conversation to avoid situations where the implementation does not fit long term vision and roadmap. _Note that this is optional. The only centralised governance in Foam is to decide what ends up in the official [template](https://github.com/foambubble/foam-template), [documentation](https://github.com/foambubble/foam) and [extension](https://github.com/foambubble/foam/tree/main/packages/foam-vscode). You are free to build whatever you want for yourself, and we'd love if you shared it with us, but you are by no means obligated to do so!_\n\n**When creating GitHub issues to discuss roadmap items, link them here.**\n\n### Known issues\n\n- [[improve-default-workspace-settings]]\n  - Discussion: [foam#270](https://github.com/foambubble/foam/issues/270)\n- Improve [[git-integration]]\n- Fix [[wikilinks]] compatibility issues\n- Simplify [[foam-file-format]]\n\n### Core features\n\n- [[renaming-files]]\n- [[unlinked-references]]\n- [[block-references]]\n- [[improved-backlinking]]\n  - UX: [[make-backlinks-more-prominent]]\n- [[materialized-backlinks]]\n- [[automatic-git-syncing]]\n- [[git-flows-for-teams]]\n- [[user-settings]]\n- [[link-reference-definitions]]\n- [[predefined-user-snippets]]\n\n### Publishing\n\n- [[officially-support-alternative-templates]]\n- [[improved-static-site-generation]]\n- [[mdx-by-default]]\n- [[search-in-published-workspace]]\n- [[graph-in-published-workspace]]\n  - Discussion: [foam#58](https://github.com/foambubble/foam/issues/58)\n- [[linking-between-published-workspaces]]\n  - Discussion: [foam#59](https://github.com/foambubble/foam/issues/59)\n- [[publishing-permissions]]\n\n### Platforms\n\n- [[cli]]\n- [[mobile-apps]]\n- [[packaged-desktop-app]]\n- [[web-editor]]\n\n### Migration\n\nThe community is working on a number of automated scripts to help you migrate to Foam. The main work of developing such a method involves exporting your notes, converting them to the Markdown format, and then making sure that the links between notes (if you had those) still work.\n\n- [[migrating-from-roam]]\n  - Discussion: [foam#55](https://github.com/foambubble/foam/issues/55)\n- [[migrating-from-obsidian]]\n  - Discussion: [foam#46](https://github.com/foambubble/foam/issues/46)\n- [[migrating-from-onenote]]\n  - Discussion: [foam#151](https://github.com/foambubble/foam/issues/151)\n- _Migration from other tools..._\n\n### Integration\n\n- _Integrations to third party tools_...\n\n### Wild ideas\n\n- [[foam-linter]]\n- [[refactoring-via-language-server-protocol]]\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[build-vs-assemble]: ../build-vs-assemble.md \"Build vs Assemble\"\n[recipes]: ../../user/recipes/recipes.md \"Recipes\"\n[contribution-guide]: ../contribution-guide.md \"Contribution Guide\"\n[wikilinks]: ../../user/features/wikilinks.md \"Wikilinks\"\n[foam-file-format]: ../foam-file-format.md \"Foam File Format\"\n[unlinked-references]: ../unlinked-references.md \"Unlinked references (stub)\"\n[make-backlinks-more-prominent]: ../../user/recipes/make-backlinks-more-prominent.md \"Make Backlinks More Prominent\"\n[materialized-backlinks]: materialized-backlinks.md \"Materialized Backlinks (stub)\"\n[automatic-git-syncing]: ../../user/recipes/automatic-git-syncing.md \"Automatically Sync with Git\"\n[link-reference-definitions]: ../../user/features/link-reference-definitions.md \"Link Reference Definitions\"\n[predefined-user-snippets]: ../../user/recipes/predefined-user-snippets.md \"Pre-defined User Snippets\"\n[mdx-by-default]: ../mdx-by-default.md \"MDX by Default(stub)\"\n[publishing-permissions]: ../publishing-permissions.md \"Publishing Permissions(stub)\"\n[cli]: ../../user/tools/cli.md \"Command Line Interface\"\n[migrating-from-roam]: ../../user/recipes/migrating-from-roam.md \"Migrating from Roam (stub)\"\n[migrating-from-obsidian]: ../../user/recipes/migrating-from-obsidian.md \"Migrating from Obsidian (stub)\"\n[migrating-from-onenote]: ../../user/recipes/migrating-from-onenote.md \"Migrating from OneNote\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/proposals/wikilinks-in-foam.md",
    "content": "# Wikilinks in Foam\n\nFoam supports standard wikilinks in the format `[[wikilink]]`.\n\nWikilinks can refer to any note or attachment in the repo: `[[note.md]]`, `[[doc.pdf]]`, `[[image.jpg]]`.\n\nThe usual wikilink syntax without extension refers to notes: `[[wikilink]]` and `[[wikilink.md]]` are equivalent.\n\nThe goal of wikilinks is to uniquely identify a file in a repo, no matter in which directory it lives.\n\nSometimes in a repo you can have files with the same name in different directories.\nFoam allows you to identify those files using the minimum effort needed to disambiguate them.\n\nThis is achieved by adding as many directories above the file needed to uniquely identify the link, e.g. `[[house/todo]]`.\n\nSee below for more details.\n\n## Goals for wikilinks in Foam\n\nWikilinks in Foam are meant to satisfy the following:\n- make it easy for users to identify a resource\n- make it interoperable with other similar note taking systems (Obsidian, Dendron, ...)\n- be easy to get started with, but satisfy growing needs\n\n## Types of wikilinks supported in Foam\n\nFoam supports two types of keys inside a wikilink: a **path** reference and an **identifier** reference:\n\n- `[[./file]]` and `[[../to/another/file]]` are **path** links to a resource, relative _from the source_\n- `[[/path/to/file]]` is a **path** link to a resource, relative _from the repo root_\n- `[[file]]` is an **identifier** of a resource (based on the filename)\n- `[[path/to/file]]` is an **identifier** of a resource (based on the path), the same is true for `[[to/file]]`\n\nIt's important to note that sometimes identifier keys can't uniquely locale a resource.\n\nA more concrete example will help:\n\n```\n/\n  projects/\n    house/\n      todo.md\n    buy-car/\n      todo.md\n      cars.md\n  work/\n    todo.md\n    notes.md\n```\n\nIn the above repo:\n\n- `[[cars]]` is a unique identifier of a resource - it can be used anywhere in the repo\n- `[[todo]]` is an non-unique identifier as it can refer to multiple resources\n- `[[house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo\n- `[[projects/house/todo]]` is a unique identifier of a resource - it can be used anywhere in the repo\n- `[[/projects/house/todo]]` is a path reference to a resource\n- `[[./todo]]` is a path reference to a resource (e.g. from `/projects/buy-car/cars.md`)\n\nBasically we could say as a rule:\n\n- if the link starts with `/` or `.` we consider it a **path** reference, in the first case from the repo root, otherwise from the source note\n- if a link doesn't start with `/` or `.` it is an **identifier**\n  - generally speaking we use the shortest identifier available to identify a resource, **but all are valid**\n    - `[[projects/buy-car/cars]]`, `[[buy-car/cars]]`, `[[cars]]` are all unique identifier to the same resource, and are all valid in a document\n    - the same can be said for `[[projects/house/todo]]` and `[[house/todo]]` - but not for `[[todo]]`, because it can refer to more than one resource\n\n## Compatibility with other apps\n\n| Scenario                    | Obsidian                        | Foam                            |\n| --------------------------- | ------------------------------- | ------------------------------- |\n| 1 `[[notes]]`               | ✔ unique identifier in repo     | ✔ unique identifier in repo     |\n| 2 `[[/work/notes]]`         | ✔ valid path from repo root     | ✔ valid path from repo root     |\n| 3 `[[work/notes]]`          | ✔ valid path from repo root     | ✔ valid identifier in repo      |\n| 4 `[[project/house/todo]]`  | ✔ valid path from repo root     | ✔ valid unique identifier       |\n| 5 `[[/project/house/todo]]` | ✔ valid path from repo root     | ✔ valid path from repo root     |\n| 6 `[[house/todo]]`          | ✔ valid unique identifier       | ✔ valid unique identifier       |\n| 7 `[[todo]]`                | ✘ ambiguous identifier          | ✘ ambiguous identifier          |\n| 8 `[[/house/todo]]`         | ✘ incorrect path from repo root | ✘ incorrect path from repo root |\n\n## Non-unique identifiers\n\nWe can't prevent non-unique identifiers from occurring in Foam (first and foremost because a file could be edited with another editor) but we can flag them. \n\nTherefore Foam follows the following strategy instead:\n\n1. there is a clear resolution mechanism (alphabetic) so that if nothing changes a non-unique identifier will always return the same note. Resolution has to be deterministic\n2. a diagnostic entry (warning or error) is showed to the user for non-unique identifiers, so she knows that she's using a \"risky\" identifier\n   1. The quick resolution for this item will show the available unique identifiers matching the non-unique one\n\n## Thanks \n\nThanks to [@memplex](https://github.com/memeplex) for helping with the thinking around this proposal."
  },
  {
    "path": "docs/dev/publishing-permissions.md",
    "content": "# Publishing Permissions(stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n## Notes\n\n- Public and private pages\n- Share specific page (with private hash)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: todo.md \"Todo\"\n[roadmap]: proposals/roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/releasing-foam.md",
    "content": "# Releasing Foam\n\n1. Get to the latest code\n   - `git checkout main && git fetch && git rebase`\n2. Sanity checks\n   - `yarn reset`\n   - `yarn test`\n3. Update change log\n   - `./packages/foam-vscode/CHANGELOG.md`\n   - `git add *`\n   - `git commit -m\"Preparation for next release\"`\n4. Update version\n   - `$ yarn version-extension <version>` (where `version` is `patch/minor/major`)\n5. Package extension\n   - `$ yarn package-extension`\n6. Publish extension\n   - `$ yarn publish-extension`\n7. Update the release notes in GitHub\n   - in GitHub, top right, click on \"releases\"\n   - select \"tags\" in top left\n   - select the tag that was just released, click \"edit\" and copy release information from changelog\n   - publish (no need to attach artifacts)\n8. Announce on Discord\n\nSteps 1 to 6 should really be replaced by a GitHub action...\n"
  },
  {
    "path": "docs/dev/testing.md",
    "content": "# Testing in Foam VS Code Extension\n\nThis document explains the testing strategy and conventions used in the Foam VS Code extension.\n\n## Test File Types\n\nWe use two distinct types of test files, each serving different purposes:\n\n### `.test.ts` Files - Pure Unit Tests\n\n- **Purpose**: Test business logic and algorithms in complete isolation\n- **Dependencies**: No VS Code APIs dependencies\n- **Environment**: Pure Jest with Node.js\n- **Speed**: Very fast execution\n- **Location**: Throughout the codebase alongside source files\n\n### `.spec.ts` Files - Integration Tests with VS Code APIs\n\n- **Purpose**: Test features that integrate with VS Code APIs and user workflows\n- **Dependencies**: Will likely depend on VS Code APIs (`vscode` module), otherwise avoid incurring the performance hit\n- **Environment**: Can run in TWO environments:\n  - **Mock Environment**: Jest with VS Code API mocks (fast)\n  - **Real VS Code**: Full VS Code extension host (slow but comprehensive)\n- **Speed**: Depends on environment (see performance section below)\n- **Location**: Primarily in `src/features/` and service layers\n\n## Key Principle: Environment Flexibility for `.spec.ts` Files\n\n**`.spec.ts` files use VS Code APIs**, but they can run in different environments:\n\n- **Mock Environment**: Uses our VS Code API mocks for speed\n- **Real VS Code**: Uses actual VS Code extension host for full integration testing\n\nThis dual-environment capability allows us to:\n\n- Run specs quickly during development (mock environment)\n- Verify full integration during CI/CD (real VS Code environment)\n- Gradually migrate specs to mock-compatible implementations\n\n## Performance Comparison\n\n| Test Type             | Environment            | Typical Duration | VS Code APIs     |\n| --------------------- | ---------------------- | ---------------- | ---------------- |\n| **`.test.ts`**        | Pure Jest              | fastest          | **No**           |\n| **`.spec.ts` (mock)** | Jest + VS Code Mocks   | fast             | **Yes** (mocked) |\n| **`.spec.ts` (real)** | VS Code Extension Host | sloooooow.       | **Yes** (real)   |\n\n## Running Tests\n\n### Available Commands\n\n- **`yarn test:unit`**: Runs `.test.ts` files (no VS Code dependencies) + `@unit-ready` marked `.spec.ts` files using mocks\n- **`yarn test:unit-without-specs`**: Runs only `.test.ts` files\n- **`yarn test:e2e`**: Runs all `.spec.ts` files in full VS Code extension host\n- **`yarn test`**: Runs both unit and e2e test suites sequentially\n\n## Mock Environment Migration\n\nWe're gradually enabling `.spec.ts` files to run in our fast mock environment while maintaining their ability to run in real VS Code.\n\n### The `@unit-ready` Annotation\n\nSpec files marked with `/* @unit-ready */` can run in both environments:\n\n```typescript\n/* @unit-ready */\nimport * as vscode from 'vscode';\n// ... test uses VS Code APIs but works with our mocks\n```\n\n### Common Migration Fixes\n\n**Configuration defaults**: Our mocks don't load package.json defaults\n\n```typescript\n// Before\nconst format = getFoamVsCodeConfig('openDailyNote.filenameFormat');\n\n// After (defensive)\nconst format = getFoamVsCodeConfig(\n  'openDailyNote.filenameFormat',\n  'yyyy-mm-dd'\n);\n```\n\n**File system operations**: Ensure proper async handling\n\n```typescript\n// Mock file operations are immediate but still async\nawait vscode.workspace.fs.writeFile(uri, content);\n```\n\n### When NOT to Migrate\n\nSome specs should remain real-VS-Code-only:\n\n- Tests verifying complex VS Code UI interactions\n- Tests requiring real file system watching with timing\n- Tests validating extension packaging or activation\n- Tests that depend on VS Code's complex internal state management\n\n## Mock System Capabilities\n\nOur `vscode-mock.ts` provides comprehensive VS Code API mocking:\n\n## Contributing Guidelines\n\nWhen adding new tests:\n\n1. **Choose the right type**:\n\n   - Use `.test.ts` for pure business logic with no VS Code dependencies\n   - Use `.spec.ts` for anything that needs VS Code APIs\n\n2. **Consider mock compatibility**:\n\n   - When writing `.spec.ts` files, consider if they could run in mock environment\n   - Add `/* @unit-ready */` if the test works with our mocks\n\n3. **Follow naming conventions**:\n\n   - Test files should be co-located with source files when possible\n   - Use descriptive test names that explain the expected behavior\n\n4. **Performance awareness**:\n   - Prefer unit tests for business logic (fastest)\n   - Use mock-compatible specs for VS Code integration (fast)\n   - Reserve real VS Code specs for complex integration scenarios (comprehensive)\n\nThis testing strategy gives us the best of both worlds: fast feedback during development and comprehensive integration verification when needed.\n\n## Graph Webview (`@foam/graph`)\n\nThe webview component has its own test stack, separate from the extension's Jest setup.\n\n- **Framework**: [Vitest](https://vitest.dev/) with [happy-dom](https://github.com/capricorn86/happy-dom) for a browser-like environment\n- **Location**: Test files live in `packages/foam-vscode/webview-ui/graph/src/`\n- **Command**: `yarn workspace @foam/graph test`\n\nThe webview tests focus on the framework-agnostic library code in `src/lib/` (graph utilities, color logic, painter). Lit component rendering tests can be added as the component grows.\n"
  },
  {
    "path": "docs/dev/todo.md",
    "content": "# Todo\n\nFeatures belong on the [[roadmap]].\n\n- [ ] Write out Roadmap\n  - [ ] Isolate tasks for MLH fellows\n- [ ] Create better structure for Recipes\n- [ ] Connect to folks at GitHub\n- [ ] Learn more about VS Code Extension APIs\n  - [ ] Workspace fs/events\n  - [ ] Reloading changes from outside vscode\n  - [ ] Expanding, editable snippets\n\nFor more things to do, check backlinks for Pages that annotate [[todo]].\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[roadmap]: proposals/roadmap.md \"Roadmap\"\n[todo]: todo.md \"Todo\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/dev/unlinked-references.md",
    "content": "# Unlinked references (stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n## Notes\n\nOne of Foam's big features is the ability to find all instances of a reference, create a page for it and update all the references to link to the new page.\n\nImplementing this is on the [[roadmap]], but for the time being you can achieve similar things by:\n\n- `Cmd` + `Shift` + `F` ( `Ctrl` + `Shift` + `F` on Windows ) to find all the references, e.g. \"Cat food\"\n- `Cmd` + `Shift` + `H` ( `Ctrl` + `Shift` + `H` on Windows ) to replace them with [[cat-food]].\n- Click any of the references to create a new note.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: todo.md \"Todo\"\n[roadmap]: proposals/roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/inbox.md",
    "content": "# Inbox\n\nUncategorised thoughts, to be added\n\n- Release notes\n- Markdown Preview\n  - It's possible to customise the markdown preview styling. **Maybe make it use local foam workspace styles for live preview of the site??**\n    - See: <https://marketplace.visualstudio.com/items?itemName=bierner.markdown-preview-github-styles>\n- Use VS Code [CodeTour](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.codetour) for onboarding\n- Investigate other similar extensions:\n  - [Unotes](https://marketplace.visualstudio.com/items?itemName=ryanmcalister.Unotes)\n  - [vscode-memo](https://github.com/svsool/vscode-memo)\n- Open in Foam\n  - When you want to open a Foam published website in your own VS Code, we could have a \"Open in Foam\" link that opens the link in VS Code via a url binding (if possible), downloads the github repo locally, and opens it as a Foam workspace.\n  - Every Foam could have a different theme even in the editor, so you'll see it like they see it\n    - UI and layout design of your workspace can become a thing\n- VS Code Notebooks API\n  - <https://code.visualstudio.com/api/extension-guides/notebook>\n- Future architecture\n  - Could we do publish-related settings as a pre-push git hook, e.g. generating footnote labels\n  - Running them on GitHub Actions to edit stuff as it comes in\n    - Ideally, we shouldn't have to touch files, should be just markdown\n- Looking at the errors/warnings/output panes makes me think, what kind of automated quality tools could we write.\n  - Deduplication, finding similarities...\n  - Thought Debugger?\n  - Knowledge Debugger?\n  - Janitor? Gardener?\n  - Foam Compiler?\n- Should support Netlify deploys out of the box\n- Foam should tick at the same frequency as your brain, and the Foam graph you build should match the mental model you have in your head, making navigation effortless.\n  - Maps have persistent topologies. As the graph grows, you should be able to visualise where an idea belongs. Maybe a literal map? And island? A DeckGL visualization?\n\nTesting: This file is served from the /docs directory.\n"
  },
  {
    "path": "docs/index.md",
    "content": "# What is Foam?\n\nFoam is a personal knowledge management system built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/). It helps you organize research, create discoverable notes, and publish your knowledge.\n\n## Key Features\n\n- **Wikilinks** - Connect thoughts with `[[double bracket]]` syntax\n- **Embeds** - Include content from other notes with `![[note]]` syntax\n- **Backlinks** - Automatically discover connections between notes\n- **Graph visualization** - See your knowledge network visually\n- **Daily notes** - Capture timestamped thoughts\n- **Templates** - Standardize note creation\n- **Tags** - Organize and filter content\n\n## Why Choose Foam?\n\n- **Free and open source** - No subscriptions or vendor lock-in\n- **Own your data** - Notes stored as standard Markdown files\n- **VS Code integration** - Leverage powerful editing and extensions\n- **Git-based** - Version control and collaboration built-in\n\nFoam is like a bathtub: _What you get out of it depends on what you put into it._\n\n<p class=\"announcement\">\n  <b>New!</b> Join <a href=\"https://foambubble.github.io/join-discord/w\" target=\"_blank\">Foam community Discord</a> for users and contributors!\n</p>\n\n<div class=\"website-only\">\n    <a class=\"github-button\" href=\"https://github.com/foambubble/foam\" data-icon=\"octicon-star\" data-size=\"large\" data-show-count=\"true\" aria-label=\"Star foambubble/foam on GitHub\">Star</a>\n    <a class=\"github-button\" href=\"https://github.com/foambubble/foam-template\" data-icon=\"octicon-repo-template\" data-size=\"large\" aria-label=\"Use this template foambubble/foam-template on GitHub\">Use this template</a>\n</div>\n\n## Table of Contents\n\n- [What is Foam?](#what-is-foam)\n  - [Key Features](#key-features)\n  - [Why Choose Foam?](#why-choose-foam)\n  - [Table of Contents](#table-of-contents)\n  - [How do I use Foam?](#how-do-i-use-foam)\n  - [What's in a Foam?](#whats-in-a-foam)\n  - [Getting started](#getting-started)\n  - [Features](#features)\n  - [Contributing](#contributing)\n  - [Thanks and attribution](#thanks-and-attribution)\n  - [License](#license)\n\n## How do I use Foam?\n\nFoam helps you create relationships between thoughts and information through:\n\n1. **Atomic notes** - Write focused markdown documents on single topics\n2. **Wikilinks** - Connect ideas with `[[double bracket]]` syntax\n3. **Backlinks** - Discover unexpected connections between notes\n4. **Graph visualization** - See your knowledge network visually\n\nSuccess with Foam depends on consistent note-taking and linking habits.\n\n## What's in a Foam?\n\nFoam combines existing tools:\n\n1. **VS Code** - Enhanced with [[recommended-extensions]] optimized for knowledge management\n2. **GitHub** - Version control, backup, and collaboration\n3. **Static site generators** - Publish to GitHub Pages, Netlify, or Vercel\n\n> This documentation was created using Foam.\n\n## Getting started\n\n**Requirements:** GitHub account and Visual Studio Code\n\n1. **Create repository** - Use the [foam-template](https://github.com/foambubble/foam-template) to generate a new repository\n\n   <a class=\"github-button\" href=\"https://github.com/foambubble/foam-template/generate\" data-icon=\"octicon-repo-template\" data-size=\"large\" aria-label=\"Use this template foambubble/foam-template on GitHub\">Use this template</a>\n\n2. **Clone and open** - Clone locally and open the folder in VS Code\n3. **Install extensions** - Click \"Install all\" when prompted for recommended extensions\n4. **Configure** - Edit `.vscode/settings.json` for your preferences\n\n**Next steps:**\n\n- Explore the [[recipes]] for usage patterns\n- Check [[frequently-asked-questions]] if you need help\n- Report issues on [GitHub](http://github.com/foambubble/foam/issues)\n\n## Features\n\nFoam leverages VS Code and [[recommended-extensions]] to provide:\n\n- **Wikilinks** with autocomplete and navigation\n- **Backlinks** panel showing connections\n- **Graph visualization** of your knowledge network\n- **Daily notes** with templates and snippets\n- **Tag system** for organization\n- **Publishing** to static sites\n\n![Short video of Foam in use](assets/images/foam-navigation-demo.gif)\n\nExplore [[recipes]] for usage patterns and workflows.\n\n## Contributing\n\nFoam is an evolving project and we welcome contributions:\n\n- Read our [[principles]] to understand Foam's philosophy\n- Follow the [[contribution-guide]] to get involved\n- Share feedback via [GitHub issues](https://github.com/foambubble/foam/issues)\n\n## Thanks and attribution\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://jevakallio.dev/\"><img src=\"https://avatars1.githubusercontent.com/u/1203949?v=4?s=60\" width=\"60px;\" alt=\"Jani Eväkallio\"/><br /><sub><b>Jani Eväkallio</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jevakallio\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jevakallio\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://joeprevite.com/\"><img src=\"https://avatars3.githubusercontent.com/u/3806031?v=4?s=60\" width=\"60px;\" alt=\"Joe Previte\"/><br /><sub><b>Joe Previte</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jsjoeio\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jsjoeio\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/riccardoferretti\"><img src=\"https://avatars3.githubusercontent.com/u/457005?v=4?s=60\" width=\"60px;\" alt=\"Riccardo\"/><br /><sub><b>Riccardo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=riccardoferretti\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=riccardoferretti\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://ojanaho.com/\"><img src=\"https://avatars0.githubusercontent.com/u/2180090?v=4?s=60\" width=\"60px;\" alt=\"Janne Ojanaho\"/><br /><sub><b>Janne Ojanaho</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jojanaho\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jojanaho\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://bypaulshen.com/\"><img src=\"https://avatars3.githubusercontent.com/u/2266187?v=4?s=60\" width=\"60px;\" alt=\"Paul Shen\"/><br /><sub><b>Paul Shen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=paulshen\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/coffenbacher\"><img src=\"https://avatars0.githubusercontent.com/u/245867?v=4?s=60\" width=\"60px;\" alt=\"coffenbacher\"/><br /><sub><b>coffenbacher</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=coffenbacher\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://mathieu.dutour.me/\"><img src=\"https://avatars2.githubusercontent.com/u/3254314?v=4?s=60\" width=\"60px;\" alt=\"Mathieu Dutour\"/><br /><sub><b>Mathieu Dutour</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=mathieudutour\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/presidentelect\"><img src=\"https://avatars2.githubusercontent.com/u/1242300?v=4?s=60\" width=\"60px;\" alt=\"Michael Hansen\"/><br /><sub><b>Michael Hansen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=presidentelect\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://klickverbot.at/\"><img src=\"https://avatars1.githubusercontent.com/u/19335?v=4?s=60\" width=\"60px;\" alt=\"David Nadlinger\"/><br /><sub><b>David Nadlinger</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dnadlinger\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://pluckd.co/\"><img src=\"https://avatars2.githubusercontent.com/u/20598571?v=4?s=60\" width=\"60px;\" alt=\"Fernando\"/><br /><sub><b>Fernando</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MrCordeiro\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jfgonzalez7\"><img src=\"https://avatars3.githubusercontent.com/u/58857736?v=4?s=60\" width=\"60px;\" alt=\"Juan Gonzalez\"/><br /><sub><b>Juan Gonzalez</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jfgonzalez7\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.louiechristie.com/\"><img src=\"https://avatars1.githubusercontent.com/u/6807448?v=4?s=60\" width=\"60px;\" alt=\"Louie Christie\"/><br /><sub><b>Louie Christie</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=louiechristie\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://supersandro.de/\"><img src=\"https://avatars2.githubusercontent.com/u/7258858?v=4?s=60\" width=\"60px;\" alt=\"Sandro\"/><br /><sub><b>Sandro</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=SuperSandro2000\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Skn0tt\"><img src=\"https://avatars1.githubusercontent.com/u/14912729?v=4?s=60\" width=\"60px;\" alt=\"Simon Knott\"/><br /><sub><b>Simon Knott</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Skn0tt\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://styfle.dev/\"><img src=\"https://avatars1.githubusercontent.com/u/229881?v=4?s=60\" width=\"60px;\" alt=\"Steven\"/><br /><sub><b>Steven</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=styfle\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Georift\"><img src=\"https://avatars2.githubusercontent.com/u/859430?v=4?s=60\" width=\"60px;\" alt=\"Tim\"/><br /><sub><b>Tim</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Georift\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/sauravkhdoolia\"><img src=\"https://avatars1.githubusercontent.com/u/34188267?v=4?s=60\" width=\"60px;\" alt=\"Saurav Khdoolia\"/><br /><sub><b>Saurav Khdoolia</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sauravkhdoolia\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://anku.netlify.com/\"><img src=\"https://avatars1.githubusercontent.com/u/22813027?v=4?s=60\" width=\"60px;\" alt=\"Ankit Tiwari\"/><br /><sub><b>Ankit Tiwari</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Documentation\">📖</a> <a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Tests\">⚠️</a> <a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ayushbaweja\"><img src=\"https://avatars1.githubusercontent.com/u/44344063?v=4?s=60\" width=\"60px;\" alt=\"Ayush Baweja\"/><br /><sub><b>Ayush Baweja</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ayushbaweja\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/TaiChi-IO\"><img src=\"https://avatars3.githubusercontent.com/u/65092992?v=4?s=60\" width=\"60px;\" alt=\"TaiChi-IO\"/><br /><sub><b>TaiChi-IO</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=TaiChi-IO\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/juanfrank77\"><img src=\"https://avatars1.githubusercontent.com/u/12146882?v=4?s=60\" width=\"60px;\" alt=\"Juan F Gonzalez \"/><br /><sub><b>Juan F Gonzalez </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=juanfrank77\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://sanketdg.github.io\"><img src=\"https://avatars3.githubusercontent.com/u/8980971?v=4?s=60\" width=\"60px;\" alt=\"Sanket Dasgupta\"/><br /><sub><b>Sanket Dasgupta</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=SanketDG\" title=\"Documentation\">📖</a> <a href=\"https://github.com/foambubble/foam/commits?author=SanketDG\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/nstafie\"><img src=\"https://avatars1.githubusercontent.com/u/10801854?v=4?s=60\" width=\"60px;\" alt=\"Nicholas Stafie\"/><br /><sub><b>Nicholas Stafie</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nstafie\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/francishamel\"><img src=\"https://avatars3.githubusercontent.com/u/36383308?v=4?s=60\" width=\"60px;\" alt=\"Francis Hamel\"/><br /><sub><b>Francis Hamel</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=francishamel\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://digiguru.co.uk\"><img src=\"https://avatars1.githubusercontent.com/u/619436?v=4?s=60\" width=\"60px;\" alt=\"digiguru\"/><br /><sub><b>digiguru</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=digiguru\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=digiguru\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/chirag-singhal\"><img src=\"https://avatars3.githubusercontent.com/u/42653703?v=4?s=60\" width=\"60px;\" alt=\"CHIRAG SINGHAL\"/><br /><sub><b>CHIRAG SINGHAL</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=chirag-singhal\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/lostintangent\"><img src=\"https://avatars3.githubusercontent.com/u/116461?v=4?s=60\" width=\"60px;\" alt=\"Jonathan Carter\"/><br /><sub><b>Jonathan Carter</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=lostintangent\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.synesthesia.co.uk\"><img src=\"https://avatars3.githubusercontent.com/u/181399?v=4?s=60\" width=\"60px;\" alt=\"Julian Elve\"/><br /><sub><b>Julian Elve</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=synesthesia\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/thomaskoppelaar\"><img src=\"https://avatars3.githubusercontent.com/u/36331365?v=4?s=60\" width=\"60px;\" alt=\"Thomas Koppelaar\"/><br /><sub><b>Thomas Koppelaar</b></sub></a><br /><a href=\"#question-thomaskoppelaar\" title=\"Answering Questions\">💬</a> <a href=\"https://github.com/foambubble/foam/commits?author=thomaskoppelaar\" title=\"Code\">💻</a> <a href=\"#userTesting-thomaskoppelaar\" title=\"User Testing\">📓</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.akshaymehra.com\"><img src=\"https://avatars1.githubusercontent.com/u/8671497?v=4?s=60\" width=\"60px;\" alt=\"Akshay\"/><br /><sub><b>Akshay</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MehraAkshay\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://johnlindquist.com\"><img src=\"https://avatars0.githubusercontent.com/u/36073?v=4?s=60\" width=\"60px;\" alt=\"John Lindquist\"/><br /><sub><b>John Lindquist</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=johnlindquist\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ashwin.run/\"><img src=\"https://avatars2.githubusercontent.com/u/1689183?v=4?s=60\" width=\"60px;\" alt=\"Ashwin Ramaswami\"/><br /><sub><b>Ashwin Ramaswami</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=epicfaace\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Klaudioz\"><img src=\"https://avatars1.githubusercontent.com/u/632625?v=4?s=60\" width=\"60px;\" alt=\"Claudio Canales\"/><br /><sub><b>Claudio Canales</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Klaudioz\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/vitaly-pevgonen\"><img src=\"https://avatars0.githubusercontent.com/u/6272738?v=4?s=60\" width=\"60px;\" alt=\"vitaly-pevgonen\"/><br /><sub><b>vitaly-pevgonen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=vitaly-pevgonen\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://dshemetov.github.io\"><img src=\"https://avatars0.githubusercontent.com/u/1810426?v=4?s=60\" width=\"60px;\" alt=\"Dmitry Shemetov\"/><br /><sub><b>Dmitry Shemetov</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dshemetov\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/hooncp\"><img src=\"https://avatars1.githubusercontent.com/u/48883554?v=4?s=60\" width=\"60px;\" alt=\"hooncp\"/><br /><sub><b>hooncp</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hooncp\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://rt-canada.ca\"><img src=\"https://avatars1.githubusercontent.com/u/13721239?v=4?s=60\" width=\"60px;\" alt=\"Martin Laws\"/><br /><sub><b>Martin Laws</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=martinlaws\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://seanksmith.me\"><img src=\"https://avatars3.githubusercontent.com/u/2085441?v=4?s=60\" width=\"60px;\" alt=\"Sean K Smith\"/><br /><sub><b>Sean K Smith</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sksmith\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/kevin-neely/\"><img src=\"https://avatars1.githubusercontent.com/u/37545028?v=4?s=60\" width=\"60px;\" alt=\"Kevin Neely\"/><br /><sub><b>Kevin Neely</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=kneely\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ariefrahmansyah.dev\"><img src=\"https://avatars3.githubusercontent.com/u/8122852?v=4?s=60\" width=\"60px;\" alt=\"Arief Rahmansyah\"/><br /><sub><b>Arief Rahmansyah</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ariefrahmansyah\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://vhanda.in\"><img src=\"https://avatars2.githubusercontent.com/u/426467?v=4?s=60\" width=\"60px;\" alt=\"Vishesh Handa\"/><br /><sub><b>Vishesh Handa</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=vHanda\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.linkedin.com/in/heroichitesh\"><img src=\"https://avatars3.githubusercontent.com/u/37622734?v=4?s=60\" width=\"60px;\" alt=\"Hitesh Kumar\"/><br /><sub><b>Hitesh Kumar</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=HeroicHitesh\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://spencerwoo.com\"><img src=\"https://avatars2.githubusercontent.com/u/32114380?v=4?s=60\" width=\"60px;\" alt=\"Spencer Woo\"/><br /><sub><b>Spencer Woo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=spencerwooo\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ingalless.com\"><img src=\"https://avatars3.githubusercontent.com/u/22981941?v=4?s=60\" width=\"60px;\" alt=\"ingalless\"/><br /><sub><b>ingalless</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ingalless\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=ingalless\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://jmg-duarte.github.io\"><img src=\"https://avatars2.githubusercontent.com/u/15343819?v=4?s=60\" width=\"60px;\" alt=\"José Duarte\"/><br /><sub><b>José Duarte</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jmg-duarte\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jmg-duarte\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.yenly.wtf\"><img src=\"https://avatars1.githubusercontent.com/u/6759658?v=4?s=60\" width=\"60px;\" alt=\"Yenly\"/><br /><sub><b>Yenly</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=yenly\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.hikerpig.cn\"><img src=\"https://avatars1.githubusercontent.com/u/2259688?v=4?s=60\" width=\"60px;\" alt=\"hikerpig\"/><br /><sub><b>hikerpig</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hikerpig\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://sigfried.org\"><img src=\"https://avatars1.githubusercontent.com/u/1586931?v=4?s=60\" width=\"60px;\" alt=\"Sigfried Gold\"/><br /><sub><b>Sigfried Gold</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Sigfried\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.tristansokol.com\"><img src=\"https://avatars3.githubusercontent.com/u/867661?v=4?s=60\" width=\"60px;\" alt=\"Tristan Sokol\"/><br /><sub><b>Tristan Sokol</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tristansokol\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://umbrellait.com\"><img src=\"https://avatars0.githubusercontent.com/u/49779373?v=4?s=60\" width=\"60px;\" alt=\"Danil Rodin\"/><br /><sub><b>Danil Rodin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=umbrellait-danil-rodin\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/scottjoewilliams/\"><img src=\"https://avatars1.githubusercontent.com/u/2026866?v=4?s=60\" width=\"60px;\" alt=\"Scott Williams\"/><br /><sub><b>Scott Williams</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=scott-joe\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://jackiexiao.github.io/blog\"><img src=\"https://avatars2.githubusercontent.com/u/18050469?v=4?s=60\" width=\"60px;\" alt=\"jackiexiao\"/><br /><sub><b>jackiexiao</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Jackiexiao\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://generativist.substack.com/\"><img src=\"https://avatars3.githubusercontent.com/u/78835?v=4?s=60\" width=\"60px;\" alt=\"John B Nelson\"/><br /><sub><b>John B Nelson</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jbn\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/asifm\"><img src=\"https://avatars2.githubusercontent.com/u/3958387?v=4?s=60\" width=\"60px;\" alt=\"Asif Mehedi\"/><br /><sub><b>Asif Mehedi</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=asifm\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/litanlitudan\"><img src=\"https://avatars2.githubusercontent.com/u/4970420?v=4?s=60\" width=\"60px;\" alt=\"Tan Li\"/><br /><sub><b>Tan Li</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=litanlitudan\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://shaunagordon.com\"><img src=\"https://avatars1.githubusercontent.com/u/579361?v=4?s=60\" width=\"60px;\" alt=\"Shauna Gordon\"/><br /><sub><b>Shauna Gordon</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ShaunaGordon\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://mcluck.tech\"><img src=\"https://avatars1.githubusercontent.com/u/1753801?v=4?s=60\" width=\"60px;\" alt=\"Mike Cluck\"/><br /><sub><b>Mike Cluck</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MCluck90\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://brandonpugh.com\"><img src=\"https://avatars1.githubusercontent.com/u/684781?v=4?s=60\" width=\"60px;\" alt=\"Brandon Pugh\"/><br /><sub><b>Brandon Pugh</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bpugh\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://max.davitt.me\"><img src=\"https://avatars1.githubusercontent.com/u/27709025?v=4?s=60\" width=\"60px;\" alt=\"Max Davitt\"/><br /><sub><b>Max Davitt</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=themaxdavitt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://briananglin.me\"><img src=\"https://avatars3.githubusercontent.com/u/2637602?v=4?s=60\" width=\"60px;\" alt=\"Brian Anglin\"/><br /><sub><b>Brian Anglin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=anglinb\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://deft.work\"><img src=\"https://avatars1.githubusercontent.com/u/1455507?v=4?s=60\" width=\"60px;\" alt=\"elswork\"/><br /><sub><b>elswork</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=elswork\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://leonh.fr/\"><img src=\"https://avatars.githubusercontent.com/u/19996318?v=4?s=60\" width=\"60px;\" alt=\"léon h\"/><br /><sub><b>léon h</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=leonhfr\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://nygaard.site\"><img src=\"https://avatars.githubusercontent.com/u/4606342?v=4?s=60\" width=\"60px;\" alt=\"Nikhil Nygaard\"/><br /><sub><b>Nikhil Nygaard</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=njnygaard\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.nitwit.se\"><img src=\"https://avatars.githubusercontent.com/u/1382124?v=4?s=60\" width=\"60px;\" alt=\"Mark Dixon\"/><br /><sub><b>Mark Dixon</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nitwit-se\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/joeltjames\"><img src=\"https://avatars.githubusercontent.com/u/3732400?v=4?s=60\" width=\"60px;\" alt=\"Joel James\"/><br /><sub><b>Joel James</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=joeltjames\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.ryo33.com\"><img src=\"https://avatars.githubusercontent.com/u/8780513?v=4?s=60\" width=\"60px;\" alt=\"Hashiguchi Ryo\"/><br /><sub><b>Hashiguchi Ryo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ryo33\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://movermeyer.com\"><img src=\"https://avatars.githubusercontent.com/u/1459385?v=4?s=60\" width=\"60px;\" alt=\"Michael Overmeyer\"/><br /><sub><b>Michael Overmeyer</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=movermeyer\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/derrickqin\"><img src=\"https://avatars.githubusercontent.com/u/3038111?v=4?s=60\" width=\"60px;\" alt=\"Derrick Qin\"/><br /><sub><b>Derrick Qin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=derrickqin\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/zomars/\"><img src=\"https://avatars.githubusercontent.com/u/3504472?v=4?s=60\" width=\"60px;\" alt=\"Omar López\"/><br /><sub><b>Omar López</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=zomars\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://robincn.com\"><img src=\"https://avatars.githubusercontent.com/u/1583193?v=4?s=60\" width=\"60px;\" alt=\"Robin King\"/><br /><sub><b>Robin King</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=RobinKing\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://twitter.com/deegovee\"><img src=\"https://avatars.githubusercontent.com/u/4730170?v=4?s=60\" width=\"60px;\" alt=\"Dheepak \"/><br /><sub><b>Dheepak </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dheepakg\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/daniel-vera-g\"><img src=\"https://avatars.githubusercontent.com/u/28257108?v=4?s=60\" width=\"60px;\" alt=\"Daniel VG\"/><br /><sub><b>Daniel VG</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=daniel-vera-g\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Barabazs\"><img src=\"https://avatars.githubusercontent.com/u/31799121?v=4?s=60\" width=\"60px;\" alt=\"Barabas\"/><br /><sub><b>Barabas</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Barabazs\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://enginveske@gmail.com\"><img src=\"https://avatars.githubusercontent.com/u/43685404?v=4?s=60\" width=\"60px;\" alt=\"Engincan VESKE\"/><br /><sub><b>Engincan VESKE</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=EngincanV\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.paulderaaij.nl\"><img src=\"https://avatars.githubusercontent.com/u/495374?v=4?s=60\" width=\"60px;\" alt=\"Paul de Raaij\"/><br /><sub><b>Paul de Raaij</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=pderaaij\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bronson\"><img src=\"https://avatars.githubusercontent.com/u/1776?v=4?s=60\" width=\"60px;\" alt=\"Scott Bronson\"/><br /><sub><b>Scott Bronson</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bronson\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://rafaelriedel.de\"><img src=\"https://avatars.githubusercontent.com/u/41793?v=4?s=60\" width=\"60px;\" alt=\"Rafael Riedel\"/><br /><sub><b>Rafael Riedel</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=rafo\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Pearcekieser\"><img src=\"https://avatars.githubusercontent.com/u/5055971?v=4?s=60\" width=\"60px;\" alt=\"Pearcekieser\"/><br /><sub><b>Pearcekieser</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Pearcekieser\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/theowenyoung\"><img src=\"https://avatars.githubusercontent.com/u/62473795?v=4?s=60\" width=\"60px;\" alt=\"Owen Young\"/><br /><sub><b>Owen Young</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=theowenyoung\" title=\"Documentation\">📖</a> <a href=\"#content-theowenyoung\" title=\"Content\">🖋</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.prashu.com\"><img src=\"https://avatars.githubusercontent.com/u/476729?v=4?s=60\" width=\"60px;\" alt=\"Prashanth Subrahmanyam\"/><br /><sub><b>Prashanth Subrahmanyam</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ksprashu\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/JonasSprenger\"><img src=\"https://avatars.githubusercontent.com/u/25108895?v=4?s=60\" width=\"60px;\" alt=\"Jonas SPRENGER\"/><br /><sub><b>Jonas SPRENGER</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=JonasSprenger\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Laptop765\"><img src=\"https://avatars.githubusercontent.com/u/1468359?v=4?s=60\" width=\"60px;\" alt=\"Paul\"/><br /><sub><b>Paul</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Laptop765\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://bandism.net/\"><img src=\"https://avatars.githubusercontent.com/u/22633385?v=4?s=60\" width=\"60px;\" alt=\"Ikko Ashimine\"/><br /><sub><b>Ikko Ashimine</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=eltociear\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/memeplex\"><img src=\"https://avatars.githubusercontent.com/u/2845433?v=4?s=60\" width=\"60px;\" alt=\"memeplex\"/><br /><sub><b>memeplex</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=memeplex\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/AndreiD049\"><img src=\"https://avatars.githubusercontent.com/u/52671223?v=4?s=60\" width=\"60px;\" alt=\"AndreiD049\"/><br /><sub><b>AndreiD049</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=AndreiD049\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/iam-yan\"><img src=\"https://avatars.githubusercontent.com/u/48427014?v=4?s=60\" width=\"60px;\" alt=\"Yan\"/><br /><sub><b>Yan</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=iam-yan\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://WikiEducator.org/User:JimTittsler\"><img src=\"https://avatars.githubusercontent.com/u/180326?v=4?s=60\" width=\"60px;\" alt=\"Jim Tittsler\"/><br /><sub><b>Jim Tittsler</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jimt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://malcolmmielle.wordpress.com/\"><img src=\"https://avatars.githubusercontent.com/u/4457840?v=4?s=60\" width=\"60px;\" alt=\"Malcolm Mielle\"/><br /><sub><b>Malcolm Mielle</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MalcolmMielle\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://snippets.page/\"><img src=\"https://avatars.githubusercontent.com/u/74916913?v=4?s=60\" width=\"60px;\" alt=\"Veesar\"/><br /><sub><b>Veesar</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=veesar\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bentongxyz\"><img src=\"https://avatars.githubusercontent.com/u/60358804?v=4?s=60\" width=\"60px;\" alt=\"bentongxyz\"/><br /><sub><b>bentongxyz</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bentongxyz\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://brianjdevries.com\"><img src=\"https://avatars.githubusercontent.com/u/42778030?v=4?s=60\" width=\"60px;\" alt=\"Brian DeVries\"/><br /><sub><b>Brian DeVries</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=techCarpenter\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://Cliffordfajardo.com\"><img src=\"https://avatars.githubusercontent.com/u/6743796?v=4?s=60\" width=\"60px;\" alt=\"Clifford Fajardo \"/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href=\"#tool-cliffordfajardo\" title=\"Tools\">🔧</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://cu-dev.ca\"><img src=\"https://avatars.githubusercontent.com/u/6589365?v=4?s=60\" width=\"60px;\" alt=\"Chris Usick\"/><br /><sub><b>Chris Usick</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=chrisUsick\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/josephdecock\"><img src=\"https://avatars.githubusercontent.com/u/1145533?v=4?s=60\" width=\"60px;\" alt=\"Joe DeCock\"/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=josephdecock\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.drewtyler.com\"><img src=\"https://avatars.githubusercontent.com/u/5640816?v=4?s=60\" width=\"60px;\" alt=\"Drew Tyler\"/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=drewtyler\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Lauviah0622\"><img src=\"https://avatars.githubusercontent.com/u/43416399?v=4?s=60\" width=\"60px;\" alt=\"Lauviah0622\"/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Lauviah0622\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.elastic.co/elastic-agent\"><img src=\"https://avatars.githubusercontent.com/u/1813008?v=4?s=60\" width=\"60px;\" alt=\"Josh Dover\"/><br /><sub><b>Josh Dover</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=joshdover\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://phelm.co.uk\"><img src=\"https://avatars.githubusercontent.com/u/4057948?v=4?s=60\" width=\"60px;\" alt=\"Phil Helm\"/><br /><sub><b>Phil Helm</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=phelma\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/lingyv-li\"><img src=\"https://avatars.githubusercontent.com/u/8937944?v=4?s=60\" width=\"60px;\" alt=\"Larry Li\"/><br /><sub><b>Larry Li</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=lingyv-li\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/infogulch\"><img src=\"https://avatars.githubusercontent.com/u/133882?v=4?s=60\" width=\"60px;\" alt=\"Joe Taber\"/><br /><sub><b>Joe Taber</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=infogulch\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.readingsnail.pe.kr\"><img src=\"https://avatars.githubusercontent.com/u/1904967?v=4?s=60\" width=\"60px;\" alt=\"Woosuk Park\"/><br /><sub><b>Woosuk Park</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=readingsnail\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.dmurph.com\"><img src=\"https://avatars.githubusercontent.com/u/294026?v=4?s=60\" width=\"60px;\" alt=\"Daniel Murphy\"/><br /><sub><b>Daniel Murphy</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dmurph\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Dominic-DallOsto\"><img src=\"https://avatars.githubusercontent.com/u/26859884?v=4?s=60\" width=\"60px;\" alt=\"Dominic D\"/><br /><sub><b>Dominic D</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Dominic-DallOsto\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://elgirafo.xyz\"><img src=\"https://avatars.githubusercontent.com/u/80516439?v=4?s=60\" width=\"60px;\" alt=\"luca\"/><br /><sub><b>luca</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=elgirafo\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Lloyd-Jackman-UKPL\"><img src=\"https://avatars.githubusercontent.com/u/55206370?v=4?s=60\" width=\"60px;\" alt=\"Lloyd Jackman\"/><br /><sub><b>Lloyd Jackman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Lloyd-Jackman-UKPL\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://sn3akiwhizper.github.io\"><img src=\"https://avatars.githubusercontent.com/u/102705294?v=4?s=60\" width=\"60px;\" alt=\"sn3akiwhizper\"/><br /><sub><b>sn3akiwhizper</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sn3akiwhizper\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://jonathanpberger.com/\"><img src=\"https://avatars.githubusercontent.com/u/41085?v=4?s=60\" width=\"60px;\" alt=\"jonathan berger\"/><br /><sub><b>jonathan berger</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jonathanpberger\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/badsketch\"><img src=\"https://avatars.githubusercontent.com/u/8953212?v=4?s=60\" width=\"60px;\" alt=\"Daniel Wang\"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=badsketch\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://yongliangliu.com\"><img src=\"https://avatars.githubusercontent.com/u/41845017?v=4?s=60\" width=\"60px;\" alt=\"Liu YongLiang\"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tlylt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://scottakerman.com\"><img src=\"https://avatars.githubusercontent.com/u/15224439?v=4?s=60\" width=\"60px;\" alt=\"Scott Akerman\"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Skakerman\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.jim-graham.net/\"><img src=\"https://avatars.githubusercontent.com/u/430293?v=4?s=60\" width=\"60px;\" alt=\"Jim Graham\"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jimgraham\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://t.me/littlepoint\"><img src=\"https://avatars.githubusercontent.com/u/7611700?v=4?s=60\" width=\"60px;\" alt=\"Zhizhen He\"/><br /><sub><b>Zhizhen He</b></sub></a><br /><a href=\"#tool-hezhizhen\" title=\"Tools\">🔧</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://amnesiak.org/me\"><img src=\"https://avatars.githubusercontent.com/u/952059?v=4?s=60\" width=\"60px;\" alt=\"Tony Cheneau\"/><br /><sub><b>Tony Cheneau</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tcheneau\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/nicholas-l\"><img src=\"https://avatars.githubusercontent.com/u/12977174?v=4?s=60\" width=\"60px;\" alt=\"Nicholas Latham\"/><br /><sub><b>Nicholas Latham</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nicholas-l\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://thara.dev\"><img src=\"https://avatars.githubusercontent.com/u/1532891?v=4?s=60\" width=\"60px;\" alt=\"Tomochika Hara\"/><br /><sub><b>Tomochika Hara</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=thara\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/dcarosone\"><img src=\"https://avatars.githubusercontent.com/u/11495017?v=4?s=60\" width=\"60px;\" alt=\"Daniel Carosone\"/><br /><sub><b>Daniel Carosone</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dcarosone\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/MABruni\"><img src=\"https://avatars.githubusercontent.com/u/100445384?v=4?s=60\" width=\"60px;\" alt=\"Miguel Angel Bruni Montero\"/><br /><sub><b>Miguel Angel Bruni Montero</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MABruni\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Walshkev\"><img src=\"https://avatars.githubusercontent.com/u/77123083?v=4?s=60\" width=\"60px;\" alt=\"Kevin Walsh \"/><br /><sub><b>Kevin Walsh </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Walshkev\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://hereistheusername.github.io/\"><img src=\"https://avatars.githubusercontent.com/u/33437051?v=4?s=60\" width=\"60px;\" alt=\"Xinglan Liu\"/><br /><sub><b>Xinglan Liu</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hereistheusername\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.hegghammer.com\"><img src=\"https://avatars.githubusercontent.com/u/64712218?v=4?s=60\" width=\"60px;\" alt=\"Thomas Hegghammer\"/><br /><sub><b>Thomas Hegghammer</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Hegghammer\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/PiotrAleksander\"><img src=\"https://avatars.githubusercontent.com/u/6314591?v=4?s=60\" width=\"60px;\" alt=\"Piotr Mrzygłosz\"/><br /><sub><b>Piotr Mrzygłosz</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=PiotrAleksander\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://schaver.com/\"><img src=\"https://avatars.githubusercontent.com/u/7584?v=4?s=60\" width=\"60px;\" alt=\"Mark Schaver\"/><br /><sub><b>Mark Schaver</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=markschaver\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/n8layman\"><img src=\"https://avatars.githubusercontent.com/u/25353944?v=4?s=60\" width=\"60px;\" alt=\"Nathan Layman\"/><br /><sub><b>Nathan Layman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=n8layman\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/emmanuel-ferdman\"><img src=\"https://avatars.githubusercontent.com/u/35470921?v=4?s=60\" width=\"60px;\" alt=\"Emmanuel Ferdman\"/><br /><sub><b>Emmanuel Ferdman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=emmanuel-ferdman\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Tenormis\"><img src=\"https://avatars.githubusercontent.com/u/61572102?v=4?s=60\" width=\"60px;\" alt=\"Tenormis\"/><br /><sub><b>Tenormis</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Tenormis\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://djon.es/blog\"><img src=\"https://avatars.githubusercontent.com/u/225052?v=4?s=60\" width=\"60px;\" alt=\"David Jones\"/><br /><sub><b>David Jones</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=djplaner\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/s-jacob-powell\"><img src=\"https://avatars.githubusercontent.com/u/109111499?v=4?s=60\" width=\"60px;\" alt=\"S. Jacob Powell\"/><br /><sub><b>S. Jacob Powell</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=s-jacob-powell\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/figdavi\"><img src=\"https://avatars.githubusercontent.com/u/99026991?v=4?s=60\" width=\"60px;\" alt=\"Davi Figueiredo\"/><br /><sub><b>Davi Figueiredo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=figdavi\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ChThH\"><img src=\"https://avatars.githubusercontent.com/u/9499483?v=4?s=60\" width=\"60px;\" alt=\"CT Hall\"/><br /><sub><b>CT Hall</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ChThH\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/meestahp\"><img src=\"https://avatars.githubusercontent.com/u/177708514?v=4?s=60\" width=\"60px;\" alt=\"meestahp\"/><br /><sub><b>meestahp</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=meestahp\" title=\"Code\">💻</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nFoam was inspired by [Roam Research](https://roamresearch.com/) and [Zettelkasten methodology](https://zettelkasten.de/posts/overview).\n\nFoam builds on [Visual Studio Code](https://code.visualstudio.com/), [GitHub](https://github.com/), and our [[recommended-extensions]].\n\n## License\n\nFoam is licensed under the [MIT license](LICENSE.txt).\n\n[recommended-extensions]: user/getting-started/recommended-extensions.md 'Recommended Extensions'\n[recipes]: user/recipes/recipes.md 'Recipes'\n[frequently-asked-questions]: user/frequently-asked-questions.md 'Frequently Asked Questions'\n[principles]: principles.md 'Principles'\n[contribution-guide]: dev/contribution-guide.md 'Contribution Guide'\n"
  },
  {
    "path": "docs/principles.md",
    "content": "# Principles\n\n## Table of Contents\n\n- [Principles](#principles)\n  - [Table of Contents](#table-of-contents)\n  - [Foam enables you to do your best thinking](#foam-enables-you-to-do-your-best-thinking)\n  - [Foam wants you to own your thoughts](#foam-wants-you-to-own-your-thoughts)\n  - [Foam helps you share your thoughts with the world.](#foam-helps-you-share-your-thoughts-with-the-world)\n  - [Foam allows people to collaborate in discovering better ways to work, together.](#foam-allows-people-to-collaborate-in-discovering-better-ways-to-work-together)\n  - [Foam is for hackers, not only for programmers](#foam-is-for-hackers-not-only-for-programmers)\n\n## Foam enables you to do your best thinking\n\n- **Foam works for you, you don't work for Foam.** You should be able to focus on your work and not fight against Foam, or having to perform fiddly operations or maintenance jobs to keep Foam happy.\n- **Foam is not a package deal.** You must be able to adopt only the parts of Foam that you want. There must be no tight coupling between Foam's features.\n- **Foam is a starting point** You must be able to customise how foam looks and feels, and combine it with other tools you find helpful in your personal workflow.\n- **Foam is not a philosophy.** Whether you use a methodology like Zettelkasten is up to you. **You should be able to use Foam without joining a cult.**\n\n## Foam wants you to own your thoughts\n\n- **Foam doesn't want your data.** You can store your documents wherever you want. Some of Foam's suggested workflows include GitHub, if you don't want to use it to store your data, or you want to stop using it in the future, you should be able to migrate to alternative storage options. If you choose to not upload your notes to any cloud service or remote repository, remember to keep frequent local and occasional offsite backups of your data!\n- **Foam should not lock you in.** Foam's content files, structure and metadata should be in interoperable format that supports migrating it to another tool if you prefer to. Users of Foam should be able to develop and share such tools freely.\n- **Foam should not leak your secrets.** Nobody, including the developers of Foam, should have access to your personal knowledge graph unless you choose to give it to them. Foams should always be private by default, but [easy to share](#foam-helps-you-share-your-thoughts-with-the-world) if you choose to.\n\n## Foam helps you share your thoughts with the world.\n\nThis principle may seem like it contradicts [Foam wants you to own your thoughts](#foam-wants-you-to-own-your-thoughts), but it's actually a compatible corollary. You should be able to do both, because:\n\n> [...] environments that build walls around good ideas tend to be less innovative in the long run than more open-ended environments. Good ideas may not want to be free, but they do want to connect, fuse, recombine. —_Steven Johnson, Where Good Ideas Come From_\n\n- **Foam should make it easy to publish your knowledge graph.** With zero code, you should be able to make your graph public to the world. You should have full control over how it looks, feels, and where it's hosted.\n- **Foam should make it easy to collaborate on ideas.** Foam should allow you to work closely together with your collaborators, and accept feedback, input and improvements from others.\n- **Foam should make it easy to publish what you choose.** Foam should double as a low-barrier blog/publishing platform, so you can share content to an audience without inviting them to intrude on your entire workspace.\n\n## Foam allows people to collaborate in discovering better ways to work, together.\n\n- **Foam is a collection of ideas.** Foam was released to the public not to share the few good ideas in it, but to learn many good ideas from others. As you improve your own workflow, share your work on your own Foam blog.\n- **Foam is open for contributions.** If you use a tool or workflow that you like that fits these principles, please contribute them back to the Foam template as [[recipes]], [[recommended-extensions]] or documentation in [this workspace](https://github.com/foambubble/foam). See also: [[contribution-guide]].\n- **Foam is open source.** Feel free to fork it, improve it and remix it. Just don't sell it, as per our [license](LICENSE.txt).\n- **Foam is not Roam.** This project was inspired by Roam Research, but we're not limited by what Roam does. No idea is too big (though if it doesn't fit with Foam's core workflow, we might make it a [[recipes]] page instead).\n\n## Foam is for hackers, not only for programmers\n\nWhile Foam uses tools popular among computer programmers, Foam should be inclusive of everyone who wants to improve their own workflow to improve themselves.\n\n- **Foam embraces the hacker mindset.** The target audience for Foam are people who look for creative ways to improve their ability to collect and organise information.\n- **Foam is not just for programmers.** If you're a programmer, feel free to write scripts and extensions to support your own workflow, and publish them for others to use, but the out of the box Foam experience should not require you to know how to do so. You should, however, be curious and open to adopting new tools that are unfamiliar to you, and evaluate whether they could work for you.\n- **Foam is for everyone** As a foam user, you support everyone's quest for knowledge and self-improvement, not only your own, or folks' who look like you. All participants in Foam repositories, discussion forums, physical and virtual meeting spaces etc are expected to respect each other as described in our [[code-of-conduct]]. **Foam is not for toxic tech bros.**\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[recipes]: user/recipes/recipes.md \"Recipes\"\n[recommended-extensions]: user/getting-started/recommended-extensions.md \"Recommended Extensions\"\n[contribution-guide]: dev/contribution-guide.md \"Contribution Guide\"\n[code-of-conduct]: dev/code-of-conduct.md \"Code of Conduct\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/features/backlinking.md",
    "content": "# Backlinks\n\nBacklinks are one of Foam's most powerful features for knowledge discovery. They automatically show you which notes reference your current note, creating a web of interconnected knowledge that reveals surprising relationships between your ideas.\n\n_[📹 Watch: Understanding and using backlinks in Foam]_\n\n## What Are Backlinks?\n\nA backlink is a connection from another note that points to the note you're currently viewing. While you create forward links intentionally with `[[wikilinks]]`, backlinks are discovered automatically by Foam.\n\n### Forward Links vs. Backlinks\n\n**Forward Links** (what you create):\n\n```markdown\n# Machine Learning Note\n\nI'm studying [[Neural Networks]] and [[Deep Learning]] concepts.\n```\n\n**Backlinks** (what Foam discovers):\nIf you're viewing the \"Neural Networks\" note, Foam shows you that \"Machine Learning Note\" links to it, even though you didn't explicitly create that reverse connection.\n\nThis bidirectional linking creates a richer knowledge network than traditional hierarchical folders.\n\n## Accessing Backlinks - Connections Panel\n\nThe Connections panel shows both forward links and backlinks:\n\n1. **Open Command Palette** (`Ctrl+Shift+P` / `Cmd+Shift+P`)\n2. **Type \"connections\"** and select \"Explorer: Focus on Connections\"\n3. **Use the filter buttons** to show only backlinks, forward links, or all connections\n\n_[📹 Watch: Finding and opening the backlinks panel]_\n\n## Using Backlinks for Knowledge Discovery\n\n### 1. Finding Unexpected Connections\n\nBacklinks often reveal relationships you didn't consciously create:\n\n**Example:** While reviewing a \"Productivity\" note, backlinks might show connections from:\n\n- A cooking recipe (time management for meal prep)\n- A fitness routine (efficient workout planning)\n- A work project (team productivity strategies)\n\nThese diverse connections can spark new insights and cross-domain learning.\n\n### 2. Identifying Important Concepts\n\nNotes with many backlinks are often central to your thinking:\n\n- **Hub concepts** that connect many ideas\n- **Frequently referenced** resources or definitions\n- **Bridge topics** that span multiple domains\n\n### 3. Building Context Around Ideas\n\nBacklinks provide context for how you use concepts across different areas:\n\n- How you apply the same principle in various projects\n- Evolution of your thinking about a topic over time\n- Different perspectives you've encountered on the same idea\n\n_[📹 Watch: Using backlinks for knowledge discovery and research]_\n"
  },
  {
    "path": "docs/user/features/block-anchors.md",
    "content": "# Block Anchors\n\nBlock anchors let you link to a specific paragraph, list item, heading, or blockquote within a note — not just to the note as a whole or to a section heading.\n\n## Adding a Block Anchor\n\nPlace `^your-id` at the end of any block element. The ID can contain letters, numbers, and hyphens.\n\nThe `^id` marker is hidden in the preview — it's metadata, not visible text.\n\n### Paragraph\n\n```markdown\nThis is an important finding from the experiment. ^key-finding\n```\n\nMulti-line paragraphs work too — put the anchor at the end of the last line:\n\n```markdown\nThe first measurements were inconclusive.\nAfter repeating the experiment, results became clear. ^experiment-result\n```\n\n### List item\n\nPlace the anchor at the end of the item text. Foam anchors the entire item, including any sub-items:\n\n```markdown\n- Mix dry ingredients thoroughly ^dry-step\n  - 2 cups flour\n  - 1 tsp salt\n- Add wet ingredients ^wet-step\n```\n\nTo anchor an entire list, place `^id` on its own line immediately after the last item (no blank line):\n\n```markdown\n- First item\n- Second item\n- Third item\n^shopping-list\n```\n\n### Heading\n\n```markdown\n## Methodology ^methodology\n```\n\nThe anchor applies to the heading line itself, not the entire section below it.\n\n### Blockquote\n\nThree placements are supported:\n\n**As the last line inside the blockquote:**\n\n```markdown\n> The only way to do great work is to love what you do.\n> ^jobs-quote\n```\n\n**On its own line immediately after the blockquote:**\n\n```markdown\n> We shall fight on the beaches,\n> we shall fight on the landing grounds.\n^churchill-beaches\n```\n\n**After the blockquote with a blank line** (useful if your markdown formatter inserts one):\n\n```markdown\n> We shall fight on the beaches,\n> we shall fight on the landing grounds.\n\n^churchill-beaches\n```\n\n### Code block\n\nPlace `^id` on its own line after the closing fence. One blank line between the fence and `^id` is also accepted (useful if your markdown formatter adds one automatically):\n\n````markdown\n```python\ndef greet(name):\n    return f\"Hello, {name}\"\n```\n^greet-function\n````\n\n### Table\n\nPlace `^id` on its own line after the table. One blank line is also accepted:\n\n```markdown\n| Name  | Score |\n| ----- | ----- |\n| Alice | 95    |\n| Bob   | 87    |\n^results-table\n```\n\n## Linking to a Block\n\nUse `[[note-name#^blockid]]` to link directly to a block:\n\n```markdown\n[[research-notes#^key-insight]]\n[[research-notes#^list-ref]]\n```\n\nFoam provides autocomplete for block IDs when you type `#^` inside a wikilink.\n\nYou can also add display text:\n\n```markdown\n[[research-notes#^key-insight|See the key insight]]\n```\n\n## Embedding a Block\n\nUse `![[note-name#^blockid]]` to embed just that block inline:\n\n```markdown\n![[research-notes#^key-insight]]\n```\n\nOnly the referenced block's content is shown — not the entire note.\n\n## Renaming a Block ID\n\nPlace your cursor on a `^blockid` anchor and press `F2` to rename it. Foam updates the anchor and all wikilinks that reference it across your workspace.\n\n## Diagnostics\n\nFoam warns you when a block link points to a `^id` that doesn't exist in the target note. A quick-fix lets you pick from the available block IDs.\n\nIf you accidentally use the same `^id` twice in one file, Foam flags the duplicate with a warning. A quick-fix replaces it with a freshly generated unique ID.\n\n## Related\n\n- [[wikilinks]] - General linking\n- [[embeds]] - Embedding notes and blocks\n"
  },
  {
    "path": "docs/user/features/commands.md",
    "content": "# Foam Commands\n\nFoam has various commands that you can explore by calling the command palette and typing \"Foam\".\n\nIn particular, some commands can be very customizable and can help with custom workflows and use cases.\n\n## foam-vscode.create-note command\n\nThis command creates a note.\nAlthough it works fine on its own, it can be customized to achieve various use cases.\nHere are the settings available for the command:\n\n- `notePath`: The path of the note to create. If relative it will be resolved against the workspace root.\n- `templatePath`: The path of the template to use. If relative it will be resolved against the workspace root.\n- `title`: The title of the note (that is, the `FOAM_TITLE` variable)\n- `text`: The text to use for the note. If also a template is provided, the template has precedence\n- `variables`: Variables to use in the text or template\n- `date`: The date used to resolve the FOAM*DATE*\\* variables. in `YYYY-MM-DD` format\n- `onFileExists?: 'overwrite' | 'open' | 'ask' | 'cancel'`: What to do in case the target file already exists\n\nTo customize a command and associate a key binding to it, open the key binding settings and add the appropriate configuration, here are some examples:\n\n- Create a note called `test note.md` with some text. If the note already exists, ask for a new name\n\n```\n{\n  \"key\": \"alt+f\",\n  \"command\": \"foam-vscode.create-note\",\n  \"args\": {\n    \"text\": \"test note ${FOAM_DATE_YEAR}\",\n    \"notePath\": \"test note.md\",\n    \"onFileExists\": \"ask\"\n  }\n}\n```\n\n- Create a note following the `weekly-note.md` template. If the note already exists, open it\n\n```\n{\n  \"key\": \"alt+g\",\n  \"command\": \"foam-vscode.create-note\",\n  \"args\": {\n    \"templatePath\": \".foam/templates/weekly-note.md\",\n    \"onFileExists\": \"open\"\n  }\n}\n```\n\n## foam-vscode.open-resource command\n\nThis command opens a resource.\n\nNormally it receives a `URI`, which identifies the resource to open.\n\nIt is also possible to pass in a filter, which will be run against the workspace resources to find one or more matches.\n\n- If there is one match, it will be opened\n- If there is more than one match, a quick pick will show up allowing the user to select the desired resource\n\nExamples:\n\n```\n{\n  \"key\": \"alt+f\",\n  \"command\": \"foam-vscode.open-resource\",\n  \"args\": {\n    \"filter\": {\n      \"title\": \"Weekly Note*\"\n    }\n  }\n}\n```\n\n## Link Conversion Commands\n\nFoam provides commands to convert between wikilink and markdown link formats.\n\n### foam-vscode.convert-wikilink-to-mdlink\n\nConverts a wikilink at the cursor position to markdown link format with a relative path.\n\nExample: `[[my-note]]` → `[My Note](../path/to/my-note.md)`\n\n### foam-vscode.convert-mdlink-to-wikilink\n\nConverts a markdown link at the cursor position to wikilink format.\n\nExample: `[My Note](../path/to/my-note.md)` → `[[my-note]]`\n\n**Usage:**\n\n1. Place your cursor inside a wikilink or markdown link\n2. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)\n3. Type \"Foam: Convert\" and select the desired conversion command\n"
  },
  {
    "path": "docs/user/features/custom-markdown-preview-styles.md",
    "content": "# Custom Markdown Preview Styles\n\nVisual Studio Code allows you to use your own CSS in the Markdown preview tab.\n\n## Instructions\n\nCustom CSS for the Markdown preview can be implemented by using the `\"markdown.styles\": []` setting in `settings.json`. The stylesheets can either be https URLs or relative paths to local files in the current workspace.\n\nFor example, to load a stylesheet called `Style.css`, we can update `settings.json` with the following line:\n\n```\n{\n  \"markdown.styles\": [\"Style.css\"]\n}\n```\n\n## Foam elements\n\n### Foam note & placeholder links\n\nIt is possible to custom style the links to a note or placeholder. The links are an `<a>` tag. For notes use the class `foam-note-link`, for placeholders use `foam-placeholder-link`.\n\n### Cyclic inclusion warnings\n\nFoams offers the functionality to include other notes in your note. This will be displayed in the preview tab. Foam recognises a cyclic inclusion of notes and will display a warning when detected. The following html is used and can be custom styled using the class `foam-cyclic-link-warning`.\n\n```html\n<div class=\"foam-cyclic-link-warning\">\n  Cyclic link detected for wikilink: ${wikilink}\n</div>\n```\n"
  },
  {
    "path": "docs/user/features/custom-snippets.md",
    "content": "# Adding Custom Snippets\n\nYou can add custom snippets whilst the default set of snippets are decided by following the below steps:\n\n1. `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type `snippets` and select `Preferences: Configure User Snippets`.\n2. The command palette will remain in focus. Search for `markdown` and select the entry entitled `markdown.json (Markdown)`.\n3. A JSON file will open. You can author your own snippets using the [documentation](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_create-your-own-snippets) to help you, or if you're using a snippet shared by another Foam user then you can copy and paste it in, as the below GIF demonstrates:\n   ![Demonstrating adding a custom snippet](../../assets/images/custom-snippet.gif)\n\nTo get started, you might consider replacing the entire contents of the `markdown.json` file opened by the steps above with the JSON in [this comment](https://github.com/foambubble/foam/pull/192#issuecomment-666736270).\n"
  },
  {
    "path": "docs/user/features/daily-notes.md",
    "content": "# Daily Notes\n\nDaily notes allow you to quickly create and access a note file for each day.\n\n## Creating Daily Notes\n\n- **Command:** `Ctrl+Shift+P` → \"Foam: Open Daily Note\"\n- **Shortcut:** `Alt+D`\n- **Snippets:** Type `/today`, `/yesterday`, `/tomorrow` in any note\n\n## Automatic Daily Notes\n\nOpen daily note automatically on VS Code startup:\n\n```json\n{\n  \"foam.openDailyNote.onStartup\": true\n}\n```\n\n## Daily Note Templates\n\nCreate `.foam/templates/daily-note.md` to customize the structure:\n\n```markdown\n---\ntype: daily-note\n---\n\n# Daily Note - $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE\n\n## Tasks\n\n- [ ]\n\n## Notes\n```\n\n## Date Snippets\n\nCreate links to recent daily notes using snippets:\n\n| Snippet      | Date          |\n| ------------ | ------------- |\n| `/today`     | today         |\n| `/tomorrow`  | tomorrow      |\n| `/yesterday` | yesterday     |\n| `/monday`    | next Monday   |\n| `/+1d`       | tomorrow      |\n| `/-3d`       | 3 days ago    |\n| `/+1w`       | in a week     |\n| `/-1m`       | one month ago |\n| `/+1y`       | in one year   |\n\n## Configuration\n\nBy default, daily notes are created as `yyyy-mm-dd.md` in the workspace's `journals` folder.\n\nTo customize your daily note location and format you can create a `.foam/templates/daily-note.md` template. See [[templates]] for more information.\n\nThere are also some settings to customize the behavior of daily notes, but they are deprecated and will be removed. Please use the `daily-note.md` template.\n\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[note-templates]: templates.md \"Note Templates\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/features/embeds.md",
    "content": "# Note Embeds\n\nEmbeds allow you to include content from other notes directly into your current note. This is powerful for creating dynamic content that updates automatically when the source note changes.\n\nIf you want to embed a dynamic list or table of notes instead of the content of one note, see [[foam-queries]].\n\n## Basic Syntax\n\nUse the embed syntax with an exclamation mark before the wikilink:\n\n```markdown\n![[note-name]]\n```\n\nThis will embed the entire content of `note-name` into your current note.\n\n## Embedding Sections\n\nYou can embed specific sections of a note by referencing the heading:\n\n```markdown\n![[note-name#Section Title]]\n```\n\n## Embedding Blocks\n\nEmbed a specific paragraph, list item, heading, or blockquote using a block anchor:\n\n```markdown\n![[note-name#^blockid]]\n```\n\nSee [[block-anchors]] for how to add `^id` markers to your notes.\n\n## Embed Types\n\nFoam supports different embedding scopes and styles that can be configured globally or overridden per embed.\n\n### Scope Modifiers\n\n- **`full`** - Include the entire note or section, including the title/heading\n- **`content`** - Include everything except the title/heading\n\nExamples:\n\n```markdown\nfull![[my-note]] # Include title + content\ncontent![[my-note]] # Content only, no title\n```\n\n### Style Modifiers\n\n- **`card`** - Display the embedded content in a bordered container\n- **`inline`** - Display the content seamlessly as part of the current note\n\nExamples:\n\n```markdown\ncard![[my-note]] # Bordered container\ninline![[my-note]] # Seamless integration\n```\n\n### Combined Modifiers\n\nYou can combine scope and style modifiers:\n\n```markdown\nfull-card![[my-note]] # Title + content in bordered container\ncontent-inline![[my-note]] # Content only, seamlessly integrated\nfull-inline![[my-note]] # Title + content, seamlessly integrated\ncontent-card![[my-note]] # Content only in bordered container\n```\n\n## Configuration\n\nSet your default embed behavior in VS Code settings:\n\n```json\n{\n  \"foam.preview.embedNoteType\": \"full-card\"\n}\n```\n\nAvailable options:\n\n- `full-card` (default)\n- `full-inline`\n- `content-card`\n- `content-inline`\n\n## Image Sizing\n\nResize images to make your documents more readable:\n\n```markdown\n![[image.png|300]]          # 300 pixels wide\n![[image.png|50%]]          # Half the container width\n```\n\n### Common Use Cases\n\n**Make large screenshots readable:**\n```markdown\n![[screenshot.png|600]]\n```\n\n**Create responsive images:**\n```markdown\n![[diagram.png|70%]]\n```\n\n**Size by width and height:**\n```markdown\n![[image.png|300x200]]\n```\n\n### Alignment\n\nCenter, left, or right align images:\n\n```markdown\n![[image.png|300|center]]\n![[image.png|300|left]]\n![[image.png|300|right]]\n```\n\n### Alt Text\n\nAdd descriptions for accessibility:\n\n```markdown\n![[chart.png|400|Monthly sales chart]]\n```\n\n### Units\n\n- `300` or `300px` - pixels (default)\n- `50%` - percentage of container\n- `20em` - relative to font size\n\n### Troubleshooting\n\n- Check image path: `![[path/to/image.png|300]]`\n- No spaces around pipes: `|300|` not `| 300 |`\n- Images only resize in preview mode, not edit mode\n- Use lowercase alignment: `center` not `Center`\n\n[block-anchors]: block-anchors.md 'Block Anchors'\n[foam-queries]: foam-queries.md 'Foam Queries'\n"
  },
  {
    "path": "docs/user/features/foam-queries.md",
    "content": "# Foam Queries\n\nFoam Queries let you show dynamic lists, tables, and counts of notes inside the Markdown preview.\n\nUse them when you want a note to answer questions such as \"show my research notes\", \"list notes linked to this topic\", or \"count notes in this folder\".\n\nFor static note embeds, see [[embeds]].\n\n## Basic Query\n\nUse a `foam-query` code block:\n\n````markdown\n```foam-query\nfilter: \"#research\"\nsort: title ASC\nlimit: 10\n```\n````\n\nThis renders a list of matching notes in the preview. Query results update as your workspace changes.\n\nIf you omit `filter`, Foam searches all notes:\n\n````markdown\n```foam-query\nformat: count\n```\n````\n\n## Query Options\n\n- `filter`: choose which notes to include\n- `select`: choose which fields to display. Default: `title` and `path`\n- `sort`: sort results, for example `title ASC` or `backlink-count DESC`. Include the sort field in `select`, otherwise it will not be available after projection.\n- `limit`: show only the first `n` matches\n- `offset`: skip the first `n` matches\n- `format`: render as `list`, `table`, or `count`\n\nWhen you select more than one field, Foam renders a table by default.\n\n## Filters\n\n### Simple Filters\n\nUse these shortcuts for common cases:\n\n- `\"#tag\"`: notes with that tag\n- `\"[[note-id]]\"`: notes linked to or from that note (use the same identifier as in wikilinks, e.g. the filename without extension)\n- `\"/regex/\"`: notes whose path matches the regular expression\n- `\"*\"`: all notes\n\nExample:\n\n````markdown\n```foam-query\nfilter: \"[[project-alpha]]\"\n```\n````\n\n### Structured Filters\n\nUse YAML when you need more control:\n\n````markdown\n```foam-query\nfilter:\n  and:\n    - tag: \"#research\"\n    - not:\n        path: \"^/archive/\"\nselect: [title, tags, backlink-count]\nsort: title ASC\n```\n````\n\nSupported filter keys:\n\n- `tag`: notes that have this tag (e.g. `tag: \"#research\"`)\n- `type`: notes of this type (e.g. `type: \"daily-note\"`)\n- `path`: notes whose path matches this regex (e.g. `path: \"^/projects/\"`)\n- `title`: notes whose title matches this regex\n- `links_to`: notes that link to the given note identifier\n- `links_from`: notes that are linked from the given note identifier\n- `expression`: a JavaScript expression evaluated against each note, e.g. `\"resource.tags.length > 2\"`. Only evaluated in trusted workspaces.\n- `and`, `or`, `not`: combine filters logically\n\n## Displayed Fields\n\nYou can select these fields:\n\n- `title`\n- `path`\n- `type`\n- `tags`\n- `aliases`\n- `sections`\n- `blocks`\n- `properties`\n- `backlink-count`\n- `outlink-count`\n\nExample table:\n\n````markdown\n```foam-query\nfilter: \"#research\"\nselect: [title, tags, backlink-count]\nsort: backlink-count DESC\nformat: table\n```\n````\n\n## Count Queries\n\nUse `count` when you only need the number of matches:\n\n````markdown\n```foam-query\nfilter:\n  path: \"^/projects/\"\nformat: count\n```\n````\n\n## JavaScript Queries\n\nUse `foam-query-js` when YAML is not enough. JavaScript queries only run in a trusted workspace.\n\n````markdown\n```foam-query-js\nconst recentResearch = foam.pages('#research')\n  .sortBy('title')\n  .limit(5)\n  .format('list');\n\nrender('Recent research notes:');\nrender(recentResearch);\n```\n````\n\n`foam.pages(filter?)` returns a query builder. Omit the filter to include all notes.\n\nAvailable builder methods:\n\n- `where(fn)`: keep only notes where `fn(note)` returns true, e.g. `.where(n => n.tags.includes('draft'))`\n- `sortBy(field, direction?)`: sort by field, direction is `'asc'` (default) or `'desc'`\n- `limit(n)`: return at most `n` results\n- `offset(n)`: skip the first `n` results\n- `select(fields)`: project to the given fields\n- `format(fmt)`: set the output format (`'list'`, `'table'`, or `'count'`)\n- `toArray()`: return results as a plain array for use in custom logic\n\nCall `render(...)` to show output in the preview. You can pass a query builder or a plain string.\n\n## Trust And Limitations\n\n- `foam-query-js` requires a trusted workspace\n- `expression` filters are only evaluated in trusted workspaces\n- Queries render in Markdown preview, not directly in the editor\n- Query results link back to the matching notes\n\n[embeds]: embeds.md \"Note Embeds\"\n"
  },
  {
    "path": "docs/user/features/graph-view.md",
    "content": "# Graph Visualization\n\nThe graph view is one of Foam's most powerful features. It transforms your collection of notes into a visual network, revealing connections between ideas that might not be obvious when reading individual notes. This guide will teach you how to use the graph view to explore, understand, and expand your knowledge base.\n\nTo see the graph execute the `Foam: Show Graph` command.\n\nYour files, such as notes and documents, are shown as the nodes of the graph along with the tags defined in your notes. The edges of the graph represent either a link between two files or a file that contains a certain tag. A node in the graph will grow in size with the number of connections it has, representing stronger or more defined concepts and topics.\n\n### The `Show Graph` command\n\n1. **Press `Ctrl+Shift+P` / `Cmd+Shift+P`**\n2. **Type \"Foam: Show Graph\"**\n3. **Press Enter**\n\nYou can set up a custom keyboard shortcut:\n\n1. **Go to File > Preferences > Keyboard Shortcuts**\n2. **Search for \"Foam: Show Graph\"**\n3. **Assign your preferred shortcut**\n\n## Graph Navigation\n\nWith the Foam graph visualization you can:\n\n- highlight a node by hovering on it, to quickly see how it's connected to the rest of your notes\n- select one or more (by keeping `shift` pressed while selecting) nodes by clicking on them, to better understand the structure of your notes\n- navigate to a note by clicking on it's node while pressing `ctrl` or `cmd`\n- automatically center the graph on the currently edited note, to immediately see its connections\n\n### Preview Mode\n\nBy default, clicking a node opens the source file in the editor. To open the markdown preview instead, enable the `foam.graph.navigateToPreview` setting:\n\n```json\n\"foam.graph.navigateToPreview\": true\n```\n\nThis gives you a two-panel layout — graph on one side, rendered preview on the other — without a source editor in between. Non-markdown files (attachments, images, etc.) always open in the editor regardless of this setting.\n\n## Filter View\n\nIf you only wish to view certain types of notes or tags, or want to hide linked attachment nodes then you can apply filters to the graph.\n\n- Open the graph view using the `Foam: Show Graph` command\n- Click the button in the top right corner of the graph view that says \"Open Controls\"\n- Expand the \"Filter By Type\" dropdown to view the selection of types that you can filter by\n- Uncheck the checkbox for any type you want to hide\n- The types displayed in this dropdown are defined by [[note-properties]] which includes Foam-standard types as well as custom types defined by you!\n\n![Graph filtering demo](../../assets/images/graph-filter.gif)\n\n## Custom Graph Styles\n\nThe Foam graph will use the current VS Code theme by default, but it's possible to customize it with the `foam.graph.style` setting.\n\n![Graph style demo](../../assets/images/graph-style.gif)\n\nA sample configuration object is provided below, you can provide as many or as little configuration as you wish:\n\n```json\n\"foam.graph.style\": {\n    \"background\": \"#202020\",\n    \"fontSize\": 12,\n    \"fontFamily\": \"Sans-Serif\",\n    \"lineColor\": \"#277da1\",\n    \"lineWidth\": 0.2,\n    \"particleWidth\": 1.0,\n    \"highlightedForeground\": \"#f9c74f\",\n    \"node\": {\n        \"note\": \"#277da1\",\n    }\n}\n```\n\n- `background` background color of the graph, adjust to increase contrast\n- `fontSize` size of the title font for each node\n- `fontFamily` font of the title font for each node\n- `lineColor` color of the edges between nodes in the graph\n- `lineWidth` thickness of the edges between nodes\n- `particleWidth` size of the particle animation showing link direction when highlighting a node\n- `highlightedForeground` color of highlighted nodes and edges when hovering over a node\n- to style individual types of nodes jump to the next section: [Style Nodes By Type](#style-nodes-by-type)\n\n### Style Nodes by Type\n\nIt is possible to customize the style of a node based on the `type` property in the YAML frontmatter of the corresponding document.\n\nThere are a few default node types defined by Foam that are displayed in the graph:\n\n- `note` defines the color for regular nodes whose documents have not overridden the `type` property.\n- `placeholder` defines the color for links that don't match any existing note. This is a [[placeholder]] because no file with such name exists.\n  - see [[wikilinks]] for more info <!--NOTE: this placeholder link should NOT have an associated file. This is to demonstrate the custom coloring-->\n- `tag` defines the color for nodes representing #tags, allowing tags to be used as graph nodes similar to backlinks.\n  - see [[tags]] for more info\n- `feature` shows an example of how you can use note types to customize the graph. It defines the color for the notes of type `feature`\n  - see [[note-properties]] for details\n\nFor example the following `backlinking.md` note:\n\n```markdown\n---\ntype: feature\n---\n# Backlinking\n\n...\n```\n\nAnd the following `settings.json`:\n\n```json\n\"foam.graph.style\": {\n    \"background\": \"#202020\",\n    \"node\": {\n        \"note\": \"#277da1\",\n        \"placeholder\": \"#545454\",\n        \"tag\": \"#f9c74f\",\n        \"feature\": \"red\",\n    }\n}\n```\n\nWill result in the following graph:\n\n![Style node by type](../../assets/images/style-node-by-type.png)\n\n## What's Next?\n\nWith graph view mastery, you're ready to explore advanced Foam features:\n\n1. **[[wikilinks]]** - Understand bidirectional connections\n2. **[[templates]]** - Use templates effectively to standardize your note creation\n3. **[[tags]]** - Organize your notes with tags\n4. **[[daily-notes]]** - Set up daily notes to establish capture routines\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[note-properties]: note-properties.md \"Note Properties\"\n[wikilinks]: wikilinks.md \"Wikilinks\"\n[tags]: tags.md \"Tags\"\n[templates]: templates.md \"Note Templates\"\n[daily-notes]: daily-notes.md \"Daily Notes\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/features/link-reference-definitions.md",
    "content": "# Link Reference Definitions\n\nLink reference definitions make your notes compatible with standard Markdown processors by converting wikilinks to standard Markdown references.\n\nFoam doesn't need references in order to work, but this feature is aimed at supporting other tools you might want to integrate with.\n\n## What Are Link Reference Definitions?\n\nFoam can automatically add reference definitions to the bottom of your notes:\n\n**Your note:**\n\n```markdown\n# Machine Learning\n\nRelated to [[Data Science]] and [[Statistics]].\n```\n\n**With reference definitions:**\n\n```markdown\n# Machine Learning\n\nRelated to [[Data Science]] and [[Statistics]].\n\n[Data Science]: data-science.md 'Data Science'\n[Statistics]: statistics.md 'Statistics'\n```\n\n## Enabling Reference Definitions\n\nConfigure in your settings:\n\n```json\n{\n  \"foam.edit.linkReferenceDefinitions\": \"withExtensions\"\n}\n```\n\n**Options:**\n\n- `\"off\"` - Disabled (default)\n- `\"withoutExtensions\"` - References without extension\n- `\"withExtensions\"` - References with extension\n\nIf you are using your notes only within Foam, you can keep definitions `off` (also to reduce clutter), otherwise pick your setting based on what is required by your use case.\n\n## How It Works\n\n1. Scans your note for wikilinks\n2. Generates reference definitions when you save\n3. Updates definitions when links change\n4. Maintains the auto-generated section\n\n## Benefits\n\n- **Standard Markdown compatibility** - Works with any Markdown processor\n- **Publishing platforms** - Compatible with GitHub Pages, Jekyll, etc.\n- **Future-proofing** - Not locked into Foam-specific format\n- **Team collaboration** - Others can read notes without Foam\n"
  },
  {
    "path": "docs/user/features/note-properties.md",
    "content": "---\ntype: feature\nkeywords: hello world, bonjour\ntags: [hello, bonjour]\n---\n\n# Note Properties\n\nAt the top of the file you can have a section where you define your properties. This section is known as the [Front-Matter](https://learn.cloudcannon.com/jekyll/introduction-to-jekyll-front-matter/) of the document and uses [YAML formatting](https://www.codeproject.com/Articles/1214409/Learn-YAML-in-five-minutes).\n\n> Be aware that this YAML section needs to be at the very top of the file to be valid.\n\nFor example, for this file, we have:\n\n```markdown\n---\ntype: feature\nkeywords: hello world, bonjour\n---\n```\n\nThis sets the `type` of this document to `feature` and sets **three** keywords for the document: `hello`, `world`, and `bonjour`. The YAML parser will treat both spaces and commas as the separators for these YAML properties. If you want to use multi-word values for these properties, you will need to combine the words with dashes or underscores (i.e. instead of `hello world`, use `hello_world` or `hello-world`).\n\n> You can set as many custom properties for a document as you like, but there are a few [special properties](#special-properties) defined by Foam.\n\n## Special Properties\n\nSome properties have special meaning for Foam:\n\n| Name    | Description                                                                                                                                                             |\n| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `title` | will assign the name to the note that you will see in the graph, regardless of the filename or the first heading (also see how to [[note-taking-in-foam]])              |\n| `type`  | can be used to style notes differently in the graph (also see [[graph-view]]). The default type for a document is `note` unless otherwise specified with this property. |\n| `tags`  | can be used to add tags to a note (see [[tags]])                                                                                                                        |\n| `alias` | can be used to add aliases to the note. an alias will show up in the link autocompletion                                                                                |\n\nFor example:\n\n```markdown\n---\ntitle: \"Note Title\"\ntype: \"daily-note\"\ntags: daily, funny, planning\nalias: alias1, alias2\n---\n```\n\n## Foam Template Properties\n\nThere also exists properties that are even more specific to Foam templates, see [[templates#Metadata]] for more info.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[write-notes-in-foam]: ../getting-started/write-notes-in-foam.md \"Writing Notes\"\n[tags]: tags.md \"Tags\"\n[graph-view]: ../features/graph-view.md \"Graph Visualization\"\n[note-taking-in-foam]: ../getting-started/note-taking-in-foam.md \"Note-Taking in Foam\"\n[note-templates#Metadata]: templates.md \"Note Templates\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/features/paste-images-from-clipboard.md",
    "content": "# Paste Images from Clipboard\n\nBy installing the [vscode-paste-image](https://github.com/mushanshitiancai/vscode-paste-image) extension, you can paste an image from the clipboard with `cmd+alt+v`.\n\nImages are automatically copied to the `/attachments` folder and a reference is added in the file where you pasted them.\n\nA prompt will ask you to confirm the name of the image, to disable it set `\"pasteImage.showFilePathConfirmInputBox\": false,` in the settings.\n\nTo change the location where the image is created, change the `pasteImage.path` property, e.g.:\n\n- `${currentFileDir}`: save the image next to the file\n- `${currentFileDir}/images`: create an `images` directory next to the file and save the image there\n"
  },
  {
    "path": "docs/user/features/resource-filters.md",
    "content": "# Resource Filters\n\nResource filters can be passed to some Foam commands to limit their scope.\n\nA filter supports the following parameters:\n\n- `tag`: include a resource if it has the given tag (e.g. `{\"tag\": \"#research\"}`)\n- `type`: include a resource if it is of the given type (e.g. `{\"type\": \"daily-note\"}`)\n- `path`: include a resource if its path matches the given regex (e.g. `{\"path\": \"/projects/*\"}`). **Note that this parameter supports regex and not globs.**\n- `expression`: include a resource if it makes the given expression `true`, where `resource` represents the resource being evaluated (e.g. `{\"expression\": \"resource.type ==='weekly-note'\"}`)\n- `title`: include a resource if the title matches the given regex (e.g. `{\"title\": \"Team meeting:*\"}`)\n\nA filter also supports some logical operators:\n\n- `and`: include a resource if it matches all the sub-parameters (e.g `{\"and\": [{\"tag\": \"#research\"}, {\"title\": \"Paper *\"}]}`)\n- `or`: include a resource if it matches any of the sub-parameters (e.g `{\"or\": [{\"tag\": \"#research\"}, {\"title\": \"Paper *\"}]}`)\n- `not`: invert the result of the nested filter (e.g. `{\"not\": {\"type\": \"daily-note\"}}`)\n\nHere is an example of a complex filter, for example to show the Foam graph only of a subset of the workspace:\n\n```\n{\n  \"key\": \"alt+f\",\n  \"command\": \"foam-vscode.show-graph\",\n  \"args\": {\n    \"filter\": {\n      \"and\": [\n        {\n          \"or\": [\n            { \"type\": 'daily-note' },\n            { \"type\": 'weekly-note' },\n            { \"path\": '/projects/*' },\n          ],\n          \"not\": {\n            { \"tag\": '#b' },\n          },\n        },\n      ],\n    }\n  }\n}\n```\n"
  },
  {
    "path": "docs/user/features/spell-checking.md",
    "content": "# Spell Checking\n\nThere are many spell checking extensions for VS Code.\n\nThe most popular spell checker for VS Code is [Code Spell Checker](https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker).\n\nAnother one of our favorites is [LTeX](https://marketplace.visualstudio.com/items?itemName=valentjn.vscode-ltex&ssr=false#overview), which is a bit heavier but offers some extra functionality.\n\nAnother popular one is [Spellright](https://marketplace.visualstudio.com/items?itemName=ban.spellright), but be mindful that there have been reports of incompatibility with the `vscode-markdown` extension (see https://github.com/foambubble/foam/issues/1068).\n"
  },
  {
    "path": "docs/user/features/tags.md",
    "content": "# Tags\n\nTags provide flexible categorization and organization for your notes beyond wikilinks and folders.\n\n## Creating Tags\n\n### Inline Tags\n\nAdd tags directly in note content:\n\n```markdown\n# Machine Learning Fundamentals\n\nThis covers basic algorithms and applications.\n\n#machine-learning #data-science #algorithms #beginner\n```\n\n### Front Matter Tags\n\nAdd tags in YAML front matter:\n\n```markdown\n---\ntags: [machine-learning, data-science, algorithms, beginner]\n---\n```\n\n### Hierarchical Tags\n\nCreate tag hierarchies using forward slashes:\n\n```markdown\n#programming/languages/python\n#programming/frameworks/react\n#work/projects/website-redesign\n#personal/health/exercise\n```\n\n## Autocompletion\n\nTyping `#` shows existing tags. In front matter, use `Ctrl+Space` for tag suggestions.\n\n## Tag Explorer\n\nUse the Tag Explorer panel in VS Code's sidebar to:\n\n- Browse hierarchical tag structure\n- Filter by tag names\n- Click tags to see all associated notes\n- View tag usage counts\n- Search for tags (click the search icon or use \"Foam: Search Tag\" command)\n\nTags also appear in the [[graph-view]] with customizable colors.\n\n## Tag Search\n\nSearch for all occurrences of a tag across your workspace:\n\n1. Use the command palette: \"Foam: Search Tag\"\n2. Or click the search icon next to a tag in the Tag Explorer panel\n\nResults appear in VS Code's search panel where you can navigate between matches.\n\n> Known limitation: this command leverages VS Code's search capability, so it's constrained by its use of regular expressions. The search is best-effort and some false search results might show up.\n\n## Custom Tag Styling\n\nCustomize tag appearance in markdown preview by adding CSS:\n\n1. Create `.foam/css/custom-tag-style.css`\n2. Add CSS targeting `.foam-tag` class:\n   ```css\n   .foam-tag {\n     color: #ffffff;\n     background-color: #000000;\n   }\n   ```\n3. Update `.vscode/settings.json`:\n   ```json\n   {\n     \"markdown.styles\": [\".foam/css/custom-tag-style.css\"]\n   }\n   ```\n\n## Tags vs Backlinks\n\nSome users prefer [[book]] backlinks instead of #book tags for categorization. Both approaches work - choose what fits your workflow.\n\n[graph-view]: graph-view.md 'Graph Visualization'\n"
  },
  {
    "path": "docs/user/features/templates.md",
    "content": "# Note Templates\n\nFoam supports note templates which let you customize the starting content of your notes instead of always starting from an empty note.\n\nFoam supports two types of templates:\n\n- **Markdown templates** (`.md` files) - Simple templates with predefined content and variables\n- **JavaScript templates** (`.js` files) - Smart templates that can adapt based on context and make intelligent decisions\n\nBoth types of templates are located in the special `.foam/templates` directory of your workspace.\n\n## Quickstart\n\n### Creating templates\n\n**For simple templates:**\n\n- Run the `Foam: Create New Template` command from the command palette\n- OR manually create a regular `.md` file in the `.foam/templates` directory\n\n**For smart templates:**\n\n- Create a `.js` file in the `.foam/templates` directory (see [JavaScript Templates](#javascript-templates) section below)\n\n![Create new template GIF](../../assets/images/create-new-template.gif)\n\n### Using templates\n\nTo create a note from a template:\n\n- Run the `Foam: Create New Note From Template` command and follow the instructions. Don't worry if you've not created a template yet! You'll be prompted to create a new simple template if none exist.\n- OR run the `Foam: Create New Note` command, which uses the special default template (`.foam/templates/new-note.md` or `.foam/templates/new-note.js`, if it exists)\n\n![Create new note from template GIF](../../assets/images/create-new-note-from-template.gif)\n\n## Special templates\n\n### Default template\n\nThe default template is used by the `Foam: Create New Note` command. Foam will look for these templates in order:\n\n1. `.foam/templates/new-note.js` (JavaScript template)\n2. `.foam/templates/new-note.md` (Markdown template)\n\nCustomize this template to contain content that you want included every time you create a note.\n\n### Default daily note template\n\nThe daily note template is used when creating daily notes (e.g. by using `Foam: Open Daily Note`). Foam will look for these templates in order:\n\n1. `.foam/templates/daily-note.js` (JavaScript template)\n2. `.foam/templates/daily-note.md` (Markdown template)\n\nFor a simple markdown template, it is _recommended_ to define the YAML Front-Matter similar to the following:\n\n```markdown\n---\ntype: daily-note\n---\n```\n\n## JavaScript Templates\n\nJavaScript templates are a powerful way to create smart, context-aware note templates that can adapt based on the situation. Unlike static Markdown templates, JavaScript templates can make intelligent decisions about what content to include.\n\n**Use JavaScript templates when you want to:**\n\n- Create different note structures based on the day of the week, time, or date\n- Adapt templates based on where the note is being created from\n- Automatically find and link related notes in your workspace\n- Generate content based on existing notes or workspace structure\n- Implement complex logic that static templates cannot handle\n\n### Basic JavaScript template structure\n\nA JavaScript template is a `.js` file that exports a function returning note content, and optionally location:\n\n```javascript\n// .foam/templates/daily-note.js\nasync function createNote({ trigger, foam, resolver, foamDate }) {\n  const today = dayjs();\n  // or you could use foamDate for day specific notes, see FOAM_DATE_* variables\n  // const day = dayjs(foamDate)\n  const formattedDay = today.format('YYYY-MM-DD');\n\n  // if you need a variable you can use the resolver\n  // const title = await resolver.resolveFromName('FOAM_TITLE');\n\n  console.log(\n    'Creating note for today: ' + formattedDay,\n    JSON.stringify(trigger)\n  );\n\n  let content = `# Daily Note - ${formattedDay}\n\n## Today's focus\n- \n\n## Notes\n- \n`;\n\n  switch (today.day()) {\n    case 1: // Monday\n      content = `# Week Planning - ${formattedDay}\n\n## This week's goals\n- [ ] Goal 1\n- [ ] Goal 2\n\n## Focus areas\n- \n`;\n      break;\n    case 5: // Friday\n      content = `# Week Review - ${formattedDay}\n\n## What went well\n- \n\n## What could be improved\n- \n\n## Next week's priorities\n- \n`;\n      break;\n  }\n\n  return {\n    content,\n    filepath: `/weekly-planning/${formattedDay}.md`,\n  };\n}\n```\n\n### Examples\n\n**Smart meeting notes:**\n\n```javascript\nasync function createNote({ trigger, foam, resolver }) {\n  const title = (await resolver.resolveFromName('FOAM_TITLE')) || 'Meeting';\n  const today = dayjs();\n  // Detect meeting type from title\n  const isStandup = title.toLowerCase().includes('standup');\n  const isReview = title.toLowerCase().includes('review');\n\n  let template = `# ${title} - ${today.format('YYYY-MM-DD')}\n\n`;\n\n  if (isStandup) {\n    template += `## What I did yesterday\n- \n\n## What I'm doing today\n- \n\n## Blockers\n- \n`;\n  } else if (isReview) {\n    template += `## What went well\n- \n\n## What could be improved\n- \n\n## Action items\n- [ ] \n`;\n  } else {\n    template += `## Agenda\n- \n\n## Notes\n- \n\n## Action items\n- [ ] \n`;\n  }\n\n  return {\n    content: template,\n    filepath: `/meetings/${title}.md`,\n  };\n}\n```\n\n### Template result format\n\nJavaScript templates must return an object with:\n\n- `content` (required): The note content as a string\n- `filepath` (required): Custom file path for the note\n  - NOTE: the path must be within the workspace.\n    - A relative path will be resolved based on the `onRelativePath` command configuration.\n    - An absolute path will be taken as is, if it falls within the workspace. Otherwise it will be considered to be from the workspace root\n\n```javascript\nreturn {\n  content: '# My Note\\n\\nContent here...',\n  filepath: 'custom-folder/my-note.md',\n};\n```\n\n### Security and limitations\n\nJavaScript templates run in a best-effort secured environment:\n\n- ✅ Can only run from trusted VS Code workspaces\n- ✅ Can access Foam workspace and utilities\n- ✅ Can use standard JavaScript features\n- ✅ Have a 30-second execution timeout\n- ❌ Cannot access the file system directly\n- ❌ Cannot make network requests\n- ❌ Cannot access Node.js modules\n\nThis increases the chances that templates stay safe while still being powerful enough for complex logic.\n\nSTILL - PLEASE BE AWARE YOU ARE EXECUTING CODE ON YOUR MACHINE. THIS SANDBOX IS NOT MEANT TO BE THE ULTIMATE SECURITY SOLUTION.\n\n**YOU MUST TRUST THE REPO CONTRIBUTORS**\n\n## Markdown templates\n\nMarkdown templates are a simple way to create notes\n\n**Use Markdown templates when you want to:**\n\n- Create simple, consistent note structures\n- Use basic variables and placeholders\n- Keep templates easy to read and modify\n\n### Variables\n\nMarkdown templates can use all the variables available in [VS Code Snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).\n\nIn addition, you can also use variables provided by Foam:\n\n| Name                 | Description                                                                                                                                                                                                                                                       |\n| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |\n| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new                                                                                                                 |\n| `FOAM_TITLE`         | The title of the note. If used, Foam will prompt you to enter a title for the note.                                                                                                                                                                               |\n| `FOAM_TITLE_SAFE`    | The title of the note in a file system safe format. If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt.                                                                                                |\n| `FOAM_SLUG`          | The sluggified title of the note (using the default github slug method). If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt.                                                                           |\n| `FOAM_CURRENT_DIR`   | The current editor's directory path. Resolves to the directory of the currently active file, or falls back to workspace root if no editor is active. Useful for creating notes in the current directory context.                                                  |\n| `FOAM_DATE_FORMAT`   | The Foam date formatted using a [dayjs format string](https://day.js.org/docs/en/display/format). Defaults to ISO 8601 with local timezone offset (e.g. `2026-03-12T22:06:55+01:00`). Use as `$FOAM_DATE_FORMAT` for the default, or `${FOAM_DATE_FORMAT:YYYY-MM-DD}` for a custom format. |\n| `FOAM_DATE_*`        | `FOAM_DATE_YEAR`, `FOAM_DATE_MONTH`, `FOAM_DATE_WEEK`, `FOAM_DATE_DAY_ISO` etc. Foam-specific versions of [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). Prefer these versions over VS Code's. |\n\n### `FOAM_DATE_FORMAT` variable\n\n`FOAM_DATE_FORMAT` lets you format the Foam date using a [dayjs format string](https://day.js.org/docs/en/display/format):\n\n- `$FOAM_DATE_FORMAT` — ISO 8601 local datetime with timezone offset, e.g. `2026-03-12T22:06:55+01:00`\n- `${FOAM_DATE_FORMAT:YYYY-MM-DD}` — date only, e.g. `2026-03-12`\n- `${FOAM_DATE_FORMAT:HH:mm}` — time only, e.g. `22:06`\n\nThe format string (the part after `:`) uses [dayjs tokens](https://day.js.org/docs/en/display/format). Common tokens: `YYYY` (4-digit year), `MM` (month), `DD` (day), `HH` (hour), `mm` (minute), `ss` (second), `Z` (timezone offset).\n\nLike all `FOAM_DATE_*` variables, this uses the Foam date rather than the current time, so it works correctly with relative daily notes (e.g. `/tomorrow`).\n\n### `FOAM_DATE_*` variables\n\nFoam defines its own set of datetime variables that have a similar behaviour as [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).\n\nSupported variables include:\n\n- `FOAM_DATE_YEAR`: 4-digit year (e.g. 2025)\n- `FOAM_DATE_MONTH`: 2-digit month (e.g. 09)\n- `FOAM_DATE_WEEK`: ISO 8601 week number (e.g. 37)\n- `FOAM_DATE_WEEK_YEAR`: the year of the ISO 8601 week number. The year that contains the Thursday of the current week, may vary from calendar year near Jan 1. Often used with `FOAM_DATE_WEEK`.\n- `FOAM_DATE_DAY_ISO`: ISO 8601 weekday number (1-7, where Monday=1, Sunday=7)\n- `FOAM_DATE_DATE`: 2-digit day of month (e.g. 15)\n- `FOAM_DATE_DAY_NAME`: Full weekday name (e.g. Monday)\n- `FOAM_DATE_DAY_NAME_SHORT`: Short weekday name (e.g. Mon)\n- `FOAM_DATE_HOUR`, `FOAM_DATE_MINUTE`, `FOAM_DATE_SECOND`, `FOAM_DATE_SECONDS_UNIX`, etc.\n\nFor example, `FOAM_DATE_YEAR` has the same behaviour as VS Code's `CURRENT_YEAR`, `FOAM_DATE_SECONDS_UNIX` has the same behaviour as `CURRENT_SECONDS_UNIX`, etc. `FOAM_DATE_DAY_ISO` returns the ISO weekday number (Monday=1, Sunday=7), which is useful for ISO week date formats like `2025-W37-5`.\n\nBy default, prefer using the `FOAM_DATE_` versions. The datetime used to compute the values will be the same for both `FOAM_DATE_` and VS Code's variables, with the exception of the creation notes using the daily note template.\n\nFor more nitty-gritty details about the supported date formats, [see here](https://github.com/foambubble/foam/blob/main/packages/foam-vscode/src/services/variable-resolver.ts).\n\n#### Relative daily notes\n\nWhen referring to daily notes, you can use the relative snippets (`/+1d`, `/tomorrow`, etc.). In these cases, the new notes will be created with the daily note template, but the datetime used should be the relative datetime, not the current datetime.\nBy using the `FOAM_DATE_` versions of the variables, the correct relative date will populate the variables, instead of the current datetime.\n\nFor example, given this daily note template (`.foam/templates/daily-note.md`):\n\n```markdown\n# $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE\n\n## Here's what I'm going to do today\n\n- Thing 1\n- Thing 2\n```\n\nWhen the `/tomorrow` snippet is used, `FOAM_DATE_` variables will be populated with tomorrow's date, as expected.\nIf instead you were to use the VS Code versions of these variables, they would be populated with today's date, not tomorrow's, causing unexpected behaviour.\n\nWhen creating notes in any other scenario, the `FOAM_DATE_` values are computed using the same datetime as the VS Code ones, so the `FOAM_DATE_` versions can be used in all scenarios by default.\n\n### Metadata\n\n**Markdown templates** can also contain metadata about the templates themselves. The metadata is defined in YAML \"Frontmatter\" blocks within the templates.\n\n| Name          | Description                                                                                                                      |\n| ------------- | -------------------------------------------------------------------------------------------------------------------------------- |\n| `filepath`    | The filepath to use when creating the new note. If the filepath is a relative filepath, it is relative to the current workspace. |\n| `name`        | A human readable name to show in the template picker.                                                                            |\n| `description` | A human readable description to show in the template picker.                                                                     |\n\nFoam-specific variables (e.g. `$FOAM_TITLE`) can be used within template metadata. However, VS Code snippet variables are ([currently](https://github.com/foambubble/foam/pull/655)) not supported.\n\n#### `filepath` attribute\n\nIt is possible to vary the `filepath` value based on the current date using the `FOAM_DATE_*` variables. This is especially useful for the [[daily-notes]] template if you wish to organize by years, months, etc. Below is an example of a daily-note template metadata section that will create new daily notes under the `journal/YEAR/MONTH-MONTH_NAME/` filepath. For example, when a note is created on November 15, 2022, a new file will be created at `C:\\Users\\foam_user\\foam_notes\\journal\\2022\\11-Nov\\2022-11-15-daily-note.md`. This method also respects the creation of daily notes relative to the current date (i.e. `/+1d`).\n\n```markdown\n---\ntype: daily-note\nfoam_template:\n  description: Daily Note\n  filepath: '/journal/$FOAM_DATE_YEAR/$FOAM_DATE_MONTH-$FOAM_DATE_MONTH_NAME_SHORT/$FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE-daily-note.md'\n---\n\n# $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE Daily Notes\n```\n\n##### Creating notes in the current directory\n\nTo create notes in the same directory as your currently active file, use the `FOAM_CURRENT_DIR` variable in your template's `filepath`:\n\n```markdown\n---\nfoam_template:\n  name: Current Directory Note\n  filepath: '$FOAM_CURRENT_DIR/$FOAM_SLUG.md'\n---\n\n# $FOAM_TITLE\n\n$FOAM_SELECTED_TEXT\n```\n\n**Best practices for filepath patterns:**\n\n- **Explicit current directory:** `$FOAM_CURRENT_DIR/$FOAM_SLUG.md` - Creates notes in the current editor's directory\n- **Workspace root:** `/$FOAM_SLUG.md` - Always creates notes in workspace root\n- **Subdirectories:** `$FOAM_CURRENT_DIR/meetings/$FOAM_SLUG.md` - Creates notes in subdirectories relative to current location\n\nThe `FOAM_CURRENT_DIR` approach is recommended over relative paths (like `./file.md`) because it makes the template's behavior explicit and doesn't depend on configuration settings.\n\n#### `name` and `description` attributes\n\nThese attributes provide a human readable name and description to be shown in the template picker (e.g. When a user uses the `Foam: Create New Note From Template` command):\n\n![Template Picker annotated with attributes](../../assets/images/template-picker-annotated.png)\n\n#### Adding template metadata to an existing YAML Frontmatter block\n\nIf your template already has a YAML Frontmatter block, you can add the Foam template metadata to it.\n\nFoam only supports adding the template metadata to _YAML_ Frontmatter blocks. If the existing Frontmatter block uses some other format (e.g. JSON), you will have to add the template metadata to its own YAML Frontmatter block.\n\nFurther, the template metadata must be provided as a [YAML block mapping](https://yaml.org/spec/1.2/spec.html#id2798057), with the attributes placed on the lines immediately following the `foam_template` line:\n\n```yaml\n---\nexisting_frontmatter: \"Existing Frontmatter block\"\nfoam_template: # this is a YAML \"Block\" mapping (\"Flow\" mappings aren't supported)\n  name: My Note Template # Attributes must be on the lines immediately following `foam_template`\n  description: This is my note template\n  filepath: `journal/$FOAM_TITLE.md`\n---\nThis is the rest of the template\n```\n\n#### Adding template metadata to its own YAML Frontmatter block\n\nYou can add the template metadata to its own YAML Frontmatter block at the start of the template:\n\n```yaml\n---\nfoam_template:\n  name: My Note Template\n  description: This is my note template\n  filepath: 'journal/$FOAM_TITLE.md'\n---\nThis is the rest of the template\n```\n\nIf the note already has a Frontmatter block, a Foam-specific Frontmatter block can be added to the start of the template. The Foam-specific Frontmatter block must always be placed at the very beginning of the file, and only whitespace can separate the two Frontmatter blocks.\n\n```yaml\n---\nfoam_template:\n  name: My Note Template\n  description: This is my note template\n  filepath: 'journal/$FOAM_TITLE.md'\n---\n\n---\nexisting_frontmatter: 'Existing Frontmatter block'\n---\nThis is the rest of the template\n```\n\n[daily-notes]: daily-notes.md 'Daily Notes'\n"
  },
  {
    "path": "docs/user/features/wikilinks.md",
    "content": "# Wikilinks\n\nWikilinks are internal links that connect files in your knowledge base using `[[double bracket]]` syntax.\n\n## Creating Wikilinks\n\n1. **Type `[[`** and start typing a note name\n2. **Select from autocomplete** and press `Tab`\n3. **Navigate** with `Ctrl+Click` (`Cmd+Click` on Mac) or `F12`\n4. **Create new notes** by clicking on non-existent wikilinks\n\nExample: [[graph-view]]\n\n## Placeholders\n\nWikilinks to non-existent files create [[placeholder]] links, styled differently to show they need files created. They're useful for planning your knowledge structure.\n\nView placeholders in the graph with `Foam: Show Graph` command or in the `Placeholders` panel.\n\n## Section Links\n\nLink to specific sections using `[[note-name#Section Title]]` syntax. Foam provides autocomplete for section titles.\n\nExamples:\n\n- External file: `[link text](other-file.md#section-name)`\n- Same document: `[link text](#section-name)`\n\n## Block Links\n\nLink to a specific paragraph, list item, heading, or blockquote using `[[note-name#^blockid]]` syntax. Add a `^your-id` anchor at the end of any block element, then reference it from other notes.\n\nSee [[block-anchors]] for full details.\n\n## Directory Links\n\nLinking to a folder name navigates to that folder's index file — either `index.md` or `README.md`. This works for both wikilinks and regular markdown links:\n\n- `[[projects]]` → opens `projects/index.md` (or `projects/README.md`)\n- `[Projects](projects)` → same\n- `[Projects](projects/)` → trailing slash is ignored\n\nIf a file named `projects.md` exists alongside the `projects/` folder, it takes priority.\n\nTo disable this behavior, set `foam.links.directory.mode` to `disabled` in your VS Code settings.\n\n## Link Syncing on Rename\n\nWhen you rename or move a note or folder, Foam automatically updates all wikilinks pointing to it. This is enabled by default and can be turned off via the `foam.links.sync.enable` setting.\n\nFor standard markdown links (e.g. `[text](path/to/note.md)`), VS Code has a built-in feature that handles this. Enable it in VS Code settings: set `markdown.updateLinksOnFileMove.enabled` to `always` or `prompt`.\n\n## Markdown Compatibility\n\nFoam can automatically generate [[link-reference-definitions]] at the bottom of files to make wikilinks compatible with standard Markdown processors.\n\n## Related\n\n- [[block-anchors]] - Linking to specific blocks within a note\n- [[foam-file-format]] - Technical details\n- [[templates]] - Creating new notes\n- [[link-reference-definition-improvements]] - Current limitations\n\n[graph-visualization]: graph-visualization.md 'Graph Visualization'\n[link-reference-definitions]: link-reference-definitions.md 'Link Reference Definitions'\n[foam-file-format]: ../../dev/foam-file-format.md 'Foam File Format'\n[note-templates]: templates.md 'Note Templates'\n[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md 'Link Reference Definition Improvements'\n[block-anchors]: block-anchors.md 'Block Anchors'\n"
  },
  {
    "path": "docs/user/frequently-asked-questions.md",
    "content": "# Frequently Asked Questions\n\n- [Frequently Asked Questions](#frequently-asked-questions)\n  - [Links/Graphs/BackLinks don't work. How do I enable them?](#linksgraphsbacklinks-dont-work-how-do-i-enable-them)\n  - [I don't want Foam enabled for all my workspaces](#i-dont-want-foam-enabled-for-all-my-workspaces)\n  - [I want to publish the graph view to GitHub pages or Vercel](#i-want-to-publish-the-graph-view-to-github-pages-or-vercel)\n\n## Links/Graphs/BackLinks don't work. How do I enable them?\n\n- Ensure that you have all the [[recommended-extensions]] installed in Visual Studio Code\n- Reload Visual Studio Code by running `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type \"reload\" and run the **Developer: Reload Window** command to for the updated extensions take effect\n- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]\n\n## I don't want Foam enabled for all my workspaces\n\nAny extension you install in Visual Studio Code is enabled by default. Given the philosophy of Foam, it works out of the box without doing any configuration upfront. In case you want to disable Foam for a specific workspace, or disable Foam by default and enable it for specific workspaces, it is advised to follow the best practices as [documented by Visual Studio Code](https://code.visualstudio.com/docs/editor/extension-marketplace#_manage-extensions)\n\n## I want to publish the graph view to GitHub pages or Vercel\n\nIf you want a different front-end look to your published foam and a way to see your graph view, we'd recommend checking out these templates:\n\n- [foam-gatsby](https://github.com/mathieudutour/foam-gatsby-template) by [Mathieu Dutour](https://github.com/mathieudutour)\n- [foam-gatsby-kb](https://github.com/hikerpig/foam-template-gatsby-kb) by [hikerpig](https://github.com/hikerpig)\n\n[recommended-extensions]: getting-started/recommended-extensions.md 'Recommended Extensions'\n[foam-file-format]: ../dev/foam-file-format.md 'Foam File Format'\n[wikilinks]: features/wikilinks.md 'Wikilinks'\n"
  },
  {
    "path": "docs/user/getting-started/first-workspace.md",
    "content": "# Creating Your First Workspace\n\nA Foam workspace is where all your notes, ideas, and knowledge live. Think of it as your digital garden where thoughts can grow and connect. This guide will help you set up a workspace that's organized, scalable, and tailored to your thinking style.\n\n## Understanding Workspaces\n\nA Foam workspace is simply a folder containing **Markdown files** (`.md`) - your actual notes.\n\nOptionally it can contain:\n\n- **Configuration files** - VS Code settings and Foam preferences\n- **Assets** - images, attachments, and other media\n- **Templates** - reusable note structures\n\n### Single vs. Multiple Workspaces\n\n**Recommended: Single Workspace**\n\n- Keep all your knowledge in one place\n- Better link discovery and graph visualization\n- Easier to maintain and backup\n- Follows the \"unified knowledge base\" principle\n\n**Deprecated: Multiple Workspaces** (deprecated - advanced users only)\n\n- Separate professional and personal knowledge\n- Isolate sensitive information\n- Different workflows for different projects\n\nMultiple workspaces are to be considered deprecated at this point, and might become unsupported in the future.\nYou can simulate a complex workspace by using file/folder links.\n\n## Method 1: Using the Foam Template (Recommended)\n\nThe easiest way to start is with our pre-configured template:\n\n### Step 1: Create from Template\n\n1. **Visit** [github.com/foambubble/foam-template](https://github.com/foambubble/foam-template)\n2. **Click \"Use this template\"** (you'll need a GitHub account)\n3. **Name your repository** (e.g., \"john-knowledge-base\", \"my-second-brain\")\n4. **Choose visibility:**\n   - **Private** - for personal notes (recommended)\n   - **Public** - if you want to share your knowledge openly\n\n### Step 2: Clone Locally\n\n```bash\ngit clone https://github.com/yourusername/your-repo-name.git\ncd your-repo-name\n```\n\n### Step 3: Open in VS Code\n\n1. **Launch VS Code**\n2. **File > Open Folder**\n3. **Select your cloned repository folder**\n\n## Method 2: Start from Scratch\n\nFor a minimal setup:\n\n1. **Create a new folder** on your computer\n2. **Open the folder** in VS Code (`File > Open Folder`)\n\nThat's all, you can start working with your markdown files and Foam will take care of the rest.\n\n## Ideas for your knowledge base\n\n### 1. Customize Your Settings\n\nReview and adjust `.vscode/settings.json` based on your preferences:\n\n- **Daily notes location** - where your daily notes are stored\n- **Image handling** - how pasted images are organized\n- **Link format** - with or without file extensions\n\n### 2. Set Up Your Inbox\n\nCreate `inbox.md` as your default capture location:\n\n```markdown\n# Inbox\n\nQuick notes and ideas go here before being organized.\n\n## Today's Captures\n\n-\n\n## To Process\n\n-\n\n## Ideas\n\n-\n```\n\n### 3. Create Core Structure Notes\n\n## Workspace Organization Strategies\n\nEstablish your main organizational notes.\nYou can use any methodology, Foam is not opinionated.\n\nThe only recommendation is to get started, you can improve later.\n\nThe two main methods adopted by users are [PARA](https://fortelabs.com/blog/para/) and [Zettelkasten](https://zettelkasten.de/overview/).\n\n### The PARA Method\n\nOrganize around four categories:\n\n- **Projects** - Things with deadlines\n- **Areas** - Ongoing responsibilities\n- **Resources** - Future reference materials\n- **Archive** - Inactive items\n\n### Zettelkasten Approach\n\nNumber-based system for atomic ideas:\n\n- **Permanent notes** - `202501251030-idea-title.md`\n- **Literature notes** - `book-author-year.md`\n- **Index notes** - `index-topic.md`\n\n### 4. Configure Daily Notes\n\nDaily notes are perfect for:\n\n- Daily planning and reflection\n- Meeting notes\n- Journal entries\n- Quick captures\n\nTest your daily notes setup:\n\n1. **Press `Ctrl+Shift+P` / `Cmd+Shift+P`**\n2. **Type \"Foam: Open Daily Note\"**\n3. **Verify the note is created in the right location**\n\nAlternatively you can press `Alt+D` to open today's daily note, or `Alt+H` to open another day's daily note.\nUse the `.foam/templates/daily-note.md` to customize your daily note.\n\n## Best Practices for New Workspaces\n\n### 1. Start Small\n\n- Begin with just a few notes\n- Don't over-organize initially\n- Let structure emerge naturally\n\n### 2. Use Templates\n\n- Create templates for common note types\n- Maintain consistency across similar notes\n- Save time on repetitive formatting\n\n### 3. Link Early and Often\n\n- Use `[[wikilinks]]` liberally\n- Don't worry about creating \"perfect\" links\n- Foam handles broken links gracefully\n\n### 4. Regular Reviews\n\n- Weekly workspace cleanup\n- Archive completed projects\n- Identify missing connections\n\n## Syncing and Backup\n\nFoam works on simple files, you can add whatever backup method you prefer on top of it.\n\n### Git\n\nYour workspace is a Git repository:\n\n```bash\ngit add .\ngit commit -m \"Add new notes and ideas\"\ngit push origin main\n```\n\nYou can also use other VS Code extensions to manage the git synching if that's helpful.\n\n### Alternative Sync Methods\n\n- **Cloud storage** - Dropbox, OneDrive, Google Drive\n- **Local backup** - Time Machine, File History\n- **Manual export** - Regular ZIP backups\n\n## What's Next?\n\nWith your workspace set up, you're ready to:\n\n1. **[Learn note-taking fundamentals](note-taking-in-foam.md)** - Master Markdown and writing effective notes\n2. **[Explore navigation](navigation.md)** - Connect your thoughts with wikilinks\n3. **[Discover the graph view](../features/graph-view.md)** - Visualize your knowledge network\n4. **[Set up templates](../features/templates.md)** - Standardize your note creation process\n\n## Getting Help\n\nIf you encounter setup issues:\n\n- Check the [Installation Guide](installation.md) for prerequisites\n- Visit the [FAQ](../faq.md) for common workspace problems\n- Join the [Foam Community Discord](https://foambubble.github.io/join-discord/w)\n"
  },
  {
    "path": "docs/user/getting-started/get-started-with-vscode.md",
    "content": "# Using Foam with VS Code Features\n\nFoam builds on Visual Studio Code's powerful editing capabilities, integrating seamlessly with VS Code's native features to create a comprehensive knowledge management experience. This guide explores how to leverage VS Code's built-in functionality alongside Foam.\n\n### Keyboard shortcuts\n\nVS Code supports various **keyboard shortcuts**, the most important for us are:\n\n| Shortcut      | Action                        |\n| ------------- | ----------------------------- |\n| `cmd+N`       | create a new file             |\n| `cmd+S`       | save the current file         |\n| `cmd+O`       | open a file                   |\n| `cmd+P`       | use quickpick to open a file  |\n| `alt+D`       | open the daily note for today |\n| `alt+H`       | open the daily note for a day |\n| `cmd+shift+P` | invoke a command (see below)  |\n\nFor more information, see the [VS Code keyboard cheat sheets](https://code.visualstudio.com/docs/getstarted/keybindings#_keyboard-shortcuts-reference), where you can also see how to customize your keybindings.\n\n### Commands\n\nCommands make VS Code extremely powerful.\n\nTo invoke a command, press `cmd+shift+P` and select the command you want to execute.\nFor example, to see the Foam graph:\n\n- press `cmd+shift+P` to open the command bar\n- start typing `show graph`\n- select the `Foam: Show Graph` command\n\nAnd watch the magic unfold.\n\nTo see all foam commands, type \"foam\" in the command bar.\nFor more information on commands, see [commands on the VS Code site](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette).\n\nIf you want to learn more about VS Code, check out their [website](https://code.visualstudio.com/docs#first-steps).\n\n### Panels\n\nFoam integrates with VS Code panels to provide insights into individual notes and the whole knowledge base.\n\n- **`Foam: links`**: Shows all notes that link to and from the currently active note, helping you understand connections and navigate your knowledge graph\n- **`Foam: Orphaned Notes`**: Displays notes that have no incoming or outgoing links, helping you identify isolated content that might need better integration\n- **`Tag Explorer`**: Shows all tags used across your workspace in a hierarchical view, see [[tags]] for more information on tags\n- **`Foam: Graph`**: Visual representation of your note connections (also available as a separate graph view)\n\n### Styling and Themes\n\nVS Code is very configurable when it comes to themes and style. Find your ideal set up by running the command `Color Theme`.\nFor more information see the [VS Code documentation](https://code.visualstudio.com/docs/configure/themes).\n\n### Multi-Cursor Editing\n\nEdit multiple locations simultaneously for efficient note management:\n\n**Basic Multi-Cursor:**\n\n- `Alt+Click` / `Option+Click` - Add cursor at click location\n- `Ctrl+Alt+Down` / `Cmd+Option+Down` - Add cursor below\n- `Ctrl+Alt+Up` / `Cmd+Option+Up` - Add cursor above\n- `Ctrl+D` / `Cmd+D` - Select next occurrence of word\n- `Ctrl+Shift+L` / `Cmd+Shift+L` - Select all occurrences\n\n**Bulk wikilink creation:**\n\n1. **Select a word** (e.g., \"Python\")\n2. **Press `Ctrl+Shift+L`** to select all occurrences\n3. **Type `[[]]`** to wrap all instances\n4. **Arrow key** to position cursor inside brackets\n\n### Find and Replace\n\nPowerful search and replace for note maintenance:\n\n**Basic Find/Replace:**\n\n- `Ctrl+F` / `Cmd+F` - Find in current file\n- `Ctrl+H` / `Cmd+H` - Replace in current file\n- `Ctrl+Shift+F` / `Cmd+Shift+F` - Find across workspace\n- `Ctrl+Shift+H` / `Cmd+Shift+H` - Replace across workspace\n\n### Text Folding\n\nOrganize long notes with collapsible sections:\n\n**Folding Controls:**\n\n- **Click fold icons** in the gutter next to headings\n- `Ctrl+Shift+[` / `Cmd+Option+[` - Fold current section\n- `Ctrl+Shift+]` / `Cmd+Option+]` - Unfold current section\n- `Ctrl+K Ctrl+0` / `Cmd+K Cmd+0` - Fold all\n- `Ctrl+K Ctrl+J` / `Cmd+K Cmd+J` - Unfold all\n\n## File Management\n\n### Explorer Integration\n\nLeverage VS Code's file explorer for note organization:\n\n**File Operations:**\n\n- **Drag and drop** files to reorganize\n- **Right-click context menus** for quick actions\n- **New file/folder** creation with shortcuts\n- **Bulk selection** with Ctrl+Click / Cmd+Click\n\n**Quick File Actions:**\n\n- `F2` - Rename file (Foam updates links automatically)\n- `Delete` - Move to trash\n- `Ctrl+C` / `Cmd+C` then `Ctrl+V` / `Cmd+V` - Copy/paste files\n- **Right-click → Reveal in Explorer/Finder** - Open in file system\n\n### Quick Open\n\nRapid file navigation for large knowledge bases:\n\n**Quick Open Commands:**\n\n- `Ctrl+P` / `Cmd+P` - Go to file\n- `Ctrl+Shift+O` / `Cmd+Shift+O` - Go to symbol (headings in Markdown)\n- `Ctrl+T` / `Cmd+T` - Go to symbol in workspace\n- `Ctrl+G` / `Cmd+G` - Go to line number\n\n**Search Patterns:**\n\n```\n# Go to File (Ctrl+P)\nmachine       # Finds \"machine-learning.md\"\nproj alpha    # Finds \"project-alpha.md\"\ndaily/2025    # Finds files in daily/2025 folder\n\n# Go to Symbol (Ctrl+Shift+O)\n@introduction # Jump to \"Introduction\" heading\n@#setup       # Jump to \"Setup\" heading\n:50           # Go to line 50\n```\n\n## Search and Discovery\n\n### Global Search\n\nFind content across your entire knowledge base:\n\n**Search Interface (`Ctrl+Shift+F` / `Cmd+Shift+F`):**\n\n- **Search box** - Enter your query\n- **Replace box** - Toggle with replace arrow\n- **Include/Exclude patterns** - Filter by file types or folders\n- **Match case** - Case-sensitive search\n- **Match whole word** - Exact word matching\n- **Use regular expression** - Advanced pattern matching\n\n### Timeline View\n\nTrack changes to your notes over time:\n\n**Accessing Timeline:**\n\n1. **Open Explorer panel**\n2. **Expand \"Timeline\" section** at bottom\n3. **Select a file** to see its change history\n4. **Click timeline entries** to see diff views\n\n**Timeline Features:**\n\n- **Git commits** show when notes were changed\n- **File saves** track editing sessions\n- **Diff views** highlight what changed\n- **Restore points** for recovering previous versions\n\n### Outline View\n\nNavigate long notes with hierarchical structure:\n\n**Outline Panel:**\n\n1. **Enable in Explorer** (expand \"Outline\" section)\n2. **Shows heading hierarchy** for current note\n3. **Click headings** to jump to sections\n4. **Collapse/expand** sections in outline\n\n## Version Control Integration\n\n### Git Integration\n\nTrack changes to your knowledge base:\n\n**Source Control Panel:**\n\n- **View changes** - See modified files\n- **Stage changes** - Click `+` to stage files\n- **Commit changes** - Enter message and commit\n- **Sync changes** - Push/pull from remote\n\n**Git Workflow for Notes:**\n\n1. **Write and edit** your notes\n2. **Review changes** in Source Control panel\n3. **Stage relevant files** for commit\n4. **Write meaningful commit message**\n5. **Commit and push** to backup/share\n\n**Useful Git Features:**\n\n- **Diff view** - See exactly what changed\n- **File history** - Track note evolution over time\n- **Branch management** - Experiment with different organization approaches\n- **Merge conflicts** - Resolve when collaborating\n\n## Markdown Features\n\n### Preview Integration\n\nView formatted notes alongside editing:\n\n**Preview Commands:**\n\n- `Ctrl+Shift+V` / `Cmd+Shift+V` - Open preview\n- `Ctrl+K V` / `Cmd+K V` - Open preview to side\n- **Preview lock** - Pin preview to specific file\n\n**Diagrams (with Mermaid extension):**\n\n````markdown\n```mermaid\ngraph TD\n    A[Foam Workspace] --> B[Notes]\n    A --> C[Templates]\n    A --> D[Assets]\n    B --> E[Wikilinks]\n    B --> F[Tags]\n    E --> G[Graph View]\n```\n````\n\n## Extension Ecosystem\n\nExtend Foam's capabilities with complementary extensions.\nLook for them in the [VS Code Marketplace](https://marketplace.visualstudio.com/).\n\n## What's Next?\n\nWith VS Code mastery, explore advanced Foam topics:\n\n1. **[[recommended-extensions]]** - See complementary extensions to improve your note taking experience\n2. **[[publishing]]** - Share your knowledge base\n\n\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[tags]: ../features/tags.md \"Tags\"\n[recommended-extensions]: recommended-extensions.md \"Recommended Extensions\"\n[publishing]: ../publishing/publishing.md \"Publishing pages\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/getting-started/installation.md",
    "content": "# Installation\n\nGetting started with Foam is straightforward. This guide will walk you through installing everything you need to begin your knowledge management journey.\n\n## Step 1: Install Visual Studio Code\n\nFoam is built on VS Code, Microsoft's free, open-source code editor. You can download it at https://code.visualstudio.com/\n\n### Why VS Code?\n\nVS Code provides:\n\n- Excellent Markdown editing capabilities\n- Rich extension ecosystem\n- Cross-platform compatibility\n- Integrated terminal and Git support\n- Customizable interface and shortcuts\n\nTo learn more about using VS Code with Foam, check [[get-started-with-vscode]]\n\n## Step 2: Install the Foam Extension\n\nThe Foam extension adds knowledge management superpowers to VS Code.\n\n1. **Open VS Code**\n2. **Click the Extensions icon** in the sidebar (or press `Ctrl+Shift+X` / `Cmd+Shift+X`)\n3. **Search for \"Foam\"** in the extensions marketplace\n4. **Click \"Install\"** on the official Foam extension by Foam Team\n5. **Reload VS Code** when prompted\n\n### What the Foam Extension Provides\n\n- Wikilink auto-completion and navigation\n- Backlink discovery and panel\n- Graph visualization\n- Powerful note template engine\n- Daily notes functionality\n\n## Step 3: Install Recommended Extensions\n\nWhile Foam works on its own, it is focused on the networking aspect of your notes. You might want to install additional extensions to improve the editing experience or the functionality of your notes.\n\n### Useful Extensions\n\n- **Markdown All in One** - Rich Markdown editing features. Highly recommended.\n\nOther extensions:\n\n- **Spell Right** - Spell checking for your notes\n- **Paste Image** - Easily insert images from clipboard\n- **Todo Tree** - Track TODO items across your workspace\n\n## What's Next?\n\nNow that Foam is installed, you're ready to:\n\n1. **[[first-workspace]]** - Set up your knowledge base structure\n2. **[[get-started-with-vscode]]** - Learn how to use VS Code for note taking\n3. **[[note-taking-in-foam]]** - Write your first Markdown notes\n4. **[[navigation]]** - Connect your thoughts with wikilinks\n5. **[[graph-view]]** - Visualize your knowledge network\n\n## Getting Help\n\nIf you encounter issues:\n\n- Check the [[frequently-asked-questions]] for common problems\n- Visit the [Foam Community Discord](https://foambubble.github.io/join-discord/w)\n- Browse [GitHub Issues](https://github.com/foambubble/foam/issues) for known problems\n- Ask questions in [GitHub Discussions](https://github.com/foambubble/foam/discussions)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[get-started-with-vscode]: get-started-with-vscode.md \"Using Foam with VS Code Features\"\n[first-workspace]: first-workspace.md \"Creating Your First Workspace\"\n[note-taking-in-foam]: note-taking-in-foam.md \"Note-Taking in Foam\"\n[navigation]: navigation.md \"Navigation in Foam\"\n[graph-view]: ../features/graph-view.md \"Graph Visualization\"\n[frequently-asked-questions]: ../frequently-asked-questions.md \"Frequently Asked Questions\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/getting-started/keyboard-shortcuts.md",
    "content": "# Keyboard Shortcuts\n\nHere are some keyboard shortcuts you'll love when editing your notes.\n\nThis works best if you can see the result in the preview panel, run the `Markdown: Open Preview to the Side` command.\n\nYou can use either the name or the id to find each shortcut in the settings (File > Preferences > Keyboard Shortcuts) and find out what it is bound to on your system and change it according to your liking.\n\n| Shortcut       | Name            | ID                                      | Extension           | Use                                 |\n| -------------- | --------------- | --------------------------------------- | ------------------- | ----------------------------------- |\n| `alt+c`        | \\-              | markdown.extension.checkTaskList        | Markdown All in One | Toggle TODO items.                  |\n| `cmd+b`        | \\-              | markdown.extension.editing.toggleBold   | Markdown All in One | Make selection bold.                |\n| `cmd+i`        | \\-              | markdown.extension.editing.toggleItalic | Markdown All in One | Make selection italic.              |\n| `ctrl+shift+f` | Format Document | editor.action.formatDocument            | Base                | Format tables                       |\n| `cmd+shift+f`  | Find files      | workbench.action.findInFiles            | Base                | Search in workspace.                |\n| `cmd+shift+e`  | Show Explorer   | workbench.view.explorer                 | Base                | Show the file explorer.             |\n| `cmd+alt+v`    | Paste Image     | extension.pasteImage                    | Paste Image         | Paste an image from your clipboard. |\n"
  },
  {
    "path": "docs/user/getting-started/navigation.md",
    "content": "# Navigation in Foam\n\nNavigation is where Foam truly shines. Unlike traditional file systems or notebooks, Foam lets you move through your knowledge by following connections between ideas. This guide will teach you how to navigate efficiently using wikilinks, backlinks, and other powerful features.\n\n_[📹 Watch: Mastering navigation in Foam]_\n\n## Understanding Wikilinks\n\nWikilinks are the backbone of Foam navigation. They connect your thoughts and let you jump between related concepts instantly.\n\n### Basic Wikilink Syntax\n\n```markdown\nI'm learning about [[Machine Learning]] and its applications in [[Data Science]].\n\nThis reminds me of my notes on [[Python Programming]] from yesterday.\n```\n\nWhen you type `[[`, Foam shows you a list of existing notes to link to. If the note doesn't exist, Foam creates a placeholder that you can click to create the note later.\n\n### Wikilink Variations\n\n**Link to a specific heading:**\n\n```markdown\nSee the [[Project Management#Risk Assessment]] section for details.\n```\n\n**Link to a specific block:**\n\n```markdown\nSee the [[Project Management#^block-id]] paragraph for details.\n```\n\n**Link with alias:**\n\n```markdown\nAccording to [[Einstein, Albert|Einstein]], imagination is more important than knowledge.\n```\n\n### Autocomplete and Link Assistance\n\nFoam provides intelligent autocomplete when creating links:\n\n1. **Type `[[`** - Foam shows a dropdown of existing notes\n2. **Start typing** - The list filters to matching notes\n3. **Use arrow keys** to navigate suggestions\n4. **Press Enter** to insert the selected link\n\n## The Foam Graph\n\nFor a visual overview of your knowledge base, Foam offers a [[graph-view]]. This feature renders your notes as nodes and the links between them as connections, creating an interactive map of your thoughts.\n\n_[📹 Watch: Navigation with the Foam Graph]_\n\n### Using the Graph\n\n1.  **Open the Command Palette** (`Ctrl+Shift+P` / `Cmd+Shift+P`)\n2.  **Run the \"Foam: Show Graph\" command**\n3.  The graph will open in a new panel. You can:\n\n- **Click on a node** to navigate to that note.\n- **Pan and zoom** to explore different areas of your knowledge base.\n- **See how ideas cluster** and identify central concepts.\n\n## Backlinks: The Power of Reverse Navigation\n\nBacklinks show you which notes reference the current note. This creates a web of knowledge where ideas naturally connect.\n\n### Viewing Backlinks\n\n1. **Open any note**\n2. **Look for the \"Connections\" panel** in the sidebar\n3. **See all notes that link to your current note**\n4. **Click any backlink** to jump to that note\n\n## Quick Navigation Features\n\n### Command Palette Navigation\n\nPress `Ctrl+Shift+P` / `Cmd+Shift+P` and try these commands:\n\n- **\"Foam: Open Random Note\"** - Discover forgotten knowledge\n- **\"Foam: Open Daily Note\"** - Quick access to today's notes\n- **\"Go to File\"** (`Ctrl+P` / `Cmd+P`) - Fast file opening\n- **\"Go to Symbol\"** (`Ctrl+Shift+O` / `Cmd+Shift+O`) - Jump to headings within a note\n\n### File Explorer Integration\n\nThe VS Code file explorer shows your note structure:\n\n- **Click any `.md` file** to open it\n- **Use the search box** to filter files\n- **Right-click** for context menus (rename, delete, etc.)\n\nFoam also supports the Note Explorer, which is like the file explorer, but centered around the Foam metadata.\n\n### Quick Open\n\nPress `Ctrl+P` / `Cmd+P` and start typing:\n\n- **File names** - `machine` finds \"machine-learning.md\"\n- **Partial paths** - `daily/2025` finds daily notes from 2025\n- **Recent files** - Empty search shows recently opened files\n\n## Link Management and Maintenance\n\n### Finding Broken Links - Placeholders\n\nIn Foam broken links are considered placeholders for future notes.\nPlaceholders (references to non-existent notes) appear differently:\n\n- In editor: `[[missing-note]]` will be highlighted a different color\n- In preview: Shows as regular text or with special styling\n\nClicking on a placeholder in the editor will create the corresponding note.\n\n**To find all placeholders:**\n\nYou can find placeholders by looking at the `Placeholders` treeview.\n\n### Renaming and Moving Notes\n\nWhen you rename a note file:\n\n1. **Use VS Code's rename function** (`F2` key)\n2. **Foam automatically updates** all links to that note\n3. **Check the \"Problems\" panel** for any issues\n\nCurrently you cannot rename whole folders.\n\n## What's Next?\n\nWith navigation mastered, you're ready to:\n\n1. **[Explore the graph view](../features/graph-view.md)** - Visualize your knowledge network\n2. **[Learn about backlinks](../features/backlinking.md)** - Master bidirectional linking\n3. **[Set up templates](../features/templates.md)** - Standardize your note creation\n4. **[Use tags effectively](../features/tags.md)** - Add another layer of organization\n"
  },
  {
    "path": "docs/user/getting-started/note-taking-in-foam.md",
    "content": "# Note-Taking in Foam\n\nEffective note-taking is the foundation of any knowledge management system. In Foam, you'll write notes in Markdown, a simple and powerful format that's both human-readable and widely supported. This guide will teach you everything you need to know about writing great notes in Foam.\n\n## Markdown Basics\n\nMarkdown is a lightweight markup language that uses simple syntax to format text. Here are the essentials:\n\n### Headings\n\n```markdown\n# Heading 1 (Main Title)\n\n## Heading 2 (Major Section)\n\n### Heading 3 (Subsection)\n\n#### Heading 4 (Minor Section)\n```\n\n### Text Formatting\n\n```markdown\n**Bold text**\n_Italic text_\n**_Bold and italic_**\n~~Strikethrough~~\n`Inline code`\n```\n\n### Lists\n\n```markdown\n## Unordered Lists\n\n- First item\n- Second item\n  - Nested item\n  - Another nested item\n\n## Ordered Lists\n\n1. First step\n2. Second step\n   1. Sub-step\n   2. Another sub-step\n```\n\n### Links and Images\n\n```markdown\n[External link](https://example.com)\n![Image description](./assets/images/screenshot.png)\n```\n\n### Code Blocks\n\n````markdown\n```javascript\nfunction greet(name) {\n  return `Hello, ${name}!`;\n}\n```\n````\n\n### Tables\n\n```markdown\n| Column 1 | Column 2 | Column 3 |\n| -------- | -------- | -------- |\n| Data 1   | Data 2   | Data 3   |\n| Data 4   | Data 5   | Data 6   |\n```\n\n### Quotes and Dividers\n\n```markdown\n> This is a quote or important note\n> It can span multiple lines\n\n---\n\nUse three dashes for horizontal dividers\n```\n\n_[📹 Watch: Markdown syntax essentials for note-taking]_\n\n## Foam-Specific Features\n\nBeyond standard Markdown, Foam adds several powerful features:\n\n### Wikilinks\n\nConnect your notes with double brackets:\n\n```markdown\nI'm reading about [[Project Management]] and its relationship to [[Personal Productivity]].\n\nThis connects to [[2025-01-25-daily-note]] where I first had this insight.\n```\n\n### Note Embedding\n\nInclude content from other notes via [[embeds]]:\n\n```markdown\n![[Project Management#Key Principles]]\n\nThis embeds the \"Key Principles\" section from the Project Management note.\n```\n\n### Tags\n\nOrganize your content with [[tags]]:\n\n```markdown\n#productivity #learning #foam\n\nTags can be anywhere in your note and help with organization and filtering.\n```\n\nUse nested tags for better organization:\n\n```markdown\n#work/projects/website\n#learning/programming/javascript\n#personal/health/exercise\n```\n\nThose tags will show as a tree structure in the [Tag Explorer](../features/tags.md)\n\n### Note Properties (YAML Front Matter)\n\nAdd metadata to your notes:\n\n```markdown\n---\ntitle: 'Advanced Note-Taking Strategies'\ntags: [productivity, learning, methods]\ncreated: 2025-01-25\nmodified: 2025-01-25\nstatus: draft\n---\n\n# Advanced Note-Taking Strategies\n\nYour note content goes here...\n```\n\n## Writing Effective Notes\n\n### The Atomic Principle\n\nEach note should focus on one concept or idea:\n\n**Good Example:**\n\n```markdown\n# The Feynman Technique\n\nA learning method where you explain a concept in simple terms as if teaching it to someone else.\n\n## Steps\n\n1. Choose a topic to learn\n2. Explain it in simple terms\n3. Identify gaps in understanding\n4. Simplify and use analogies\n\n## Why It Works\n\n- Forces active engagement with material\n- Reveals knowledge gaps quickly\n- Improves retention through teaching\n\nRelated: [[Active Learning]] [[Study Methods]]\n```\n\n**Avoid:**\nMixing multiple unrelated concepts in one note.\n\n### Use Descriptive Titles\n\nYour note titles should clearly indicate the content:\n\n**Good:** `REST API Design Principles`\n**Good:** `Meeting Notes - Product Roadmap Review 2025-01-25`\n**Avoid:** `Stuff I Learned Today`\n**Avoid:** `Notes`\n\n### Link Generously\n\nDon't hesitate to create links, even to notes that don't exist yet:\n\n```markdown\n# Machine Learning Fundamentals\n\nMachine learning is a subset of [[Artificial Intelligence]] that focuses on creating algorithms that can learn from [[Data]].\n\nKey concepts include:\n\n- [[Supervised Learning]]\n- [[Unsupervised Learning]]\n- [[Neural Networks]]\n- [[Feature Engineering]]\n\nThis connects to my work on [[Customer Behavior Analysis]] and [[Predictive Analytics]].\n```\n\nFoam will create placeholder pages for missing notes, making it easy to fill in knowledge gaps later.\n\n## Keyboard Shortcuts\n\nEssential VS Code shortcuts for note-taking:\n\n| Shortcut                       | Action                |\n| ------------------------------ | --------------------- |\n| `Ctrl+N` / `Cmd+N`             | New file              |\n| `Ctrl+S` / `Cmd+S`             | Save file             |\n| `Ctrl+P` / `Cmd+P`             | Quick file open       |\n| `Ctrl+Shift+P` / `Cmd+Shift+P` | Command palette       |\n| `Ctrl+K V` / `Cmd+K V`         | Open Markdown preview |\n| `Ctrl+[` / `Cmd+[`             | Decrease indent       |\n| `Ctrl+]` / `Cmd+]`             | Increase indent       |\n| `Alt+Z` / `Option+Z`           | Toggle word wrap      |\n\n## What's Next?\n\nNow that you understand note-taking basics:\n\n1. **[[navigation]]** - Learn to move efficiently between notes with wikilinks\n2. **[Explore the graph view](../features/graph-view.md)** - Visualize the connections in your knowledge base\n3. **[Set up templates](../features/templates.md)** - Create reusable note structures\n4. **[Use daily notes](../features/daily-notes.md)** - Establish a daily capture routine\n\n[navigation]: navigation.md 'Navigation in Foam'\n[tags]: ../features/tags.md 'Tags'\n\n"
  },
  {
    "path": "docs/user/getting-started/recommended-extensions.md",
    "content": "# Recommended Extensions\n\nThese extensions defined in `.vscode/extensions.json` are automatically installed when you accept the workspace's recommended extensions.\n\nThis list is subject to change.\n\n- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n- [Markdown All In One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one)\n- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)\n\n## Extensions For Additional Features\n\nThese extensions are not defined in `.vscode/extensions.json`, but have been used by others and shown to play nice with Foam.\n\n- [Emojisense](https://marketplace.visualstudio.com/items?itemName=bierner.emojisense) (provides emoji autocomplete and suggestions in markdown files)\n- [Markdown Emoji](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-emoji) (adds `:smile:` syntax support, works with emojisense to provide autocomplete for this syntax)\n- [Mermaid diagrams Support](https://marketplace.visualstudio.com/items?itemName=MermaidChart.vscode-mermaid-chart) (adds syntax highlighting for Mermaid code blocks in markdown and renders Mermaid diagrams in markdown preview)\n- [Excalidraw whiteboard and sketching tool integration](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor) (create and edit hand-drawn style diagrams and sketches directly in VS Code)\n- [VSCode PDF Viewing](https://marketplace.visualstudio.com/items?itemName=tomoki1207.pdf) (view PDF files directly within VS Code without external applications)\n- [Project Manager](https://marketplace.visualstudio.com/items?itemName=alefragnani.project-manager) (easily switch between multiple projects and workspaces)\n- [Markdown Extended](https://marketplace.visualstudio.com/items?itemName=jebbs.markdown-extended) (extended markdown syntax support with additional formatting options - use with `kbd` option disabled as it conflicts with wikilinks)\n- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc) (automatic git commits for easy version management and backup of your notes)\n- [Markdown Footnotes](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-footnotes) (adds footnote syntax support `[^footnote]` to VS Code's built-in markdown preview)\n- [Todo Tree](https://marketplace.visualstudio.com/items?itemName=Gruntfuggly.todo-tree) (scans workspace for TODO, FIXME, and other comment tags, displaying them in a tree view and editor gutter)\n"
  },
  {
    "path": "docs/user/getting-started/sync-notes.md",
    "content": "# Sync notes with source control\n\nSource control is a way to precisely manage the history and content of a directory of files.\nOften used for program code, this feature is very useful for note taking as well.\n\nThere are (too) many ways to commit your changes to source control:\n\n- Using VS Code's own git integration\n  - The quick and easy way is to use the `Git: Commit All` command after editing files. The default Foam workspace settings will stage & sync all of your changes to the remote:\n- Using GitDoc for [[automatic-git-syncing]]\n- Whatever way you like to do it (CLI?)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[automatic-git-syncing]: ../recipes/automatic-git-syncing.md \"Automatically Sync with Git\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/index.md",
    "content": "# Using Foam\n\nFoam is a personal knowledge management system built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/). It helps you organize research, create discoverable notes, and publish your knowledge.\n\n> See also [[frequently-asked-questions]].\n\n## Key Features\n\n- **Wikilinks** - Connect thoughts with `[[double bracket]]` syntax\n- **Block anchors** - Link or embed specific paragraphs, list items, and headings with `[[note#^id]]`\n- **Embeds** - Include content from other notes with `![[note]]` syntax\n- **Backlinks** - Automatically discover connections between notes\n- **Graph visualization** - See your knowledge network visually\n- **Daily notes** - Capture timestamped thoughts\n- **Templates** - Standardize note creation\n- **Tags** - Organize and filter content\n\n## Why Choose Foam?\n\n- **Free and open source** - No subscriptions or vendor lock-in\n- **Own your data** - Notes stored as standard Markdown files\n- **VS Code integration** - Leverage powerful editing and extensions\n- **Git-based** - Version control and collaboration built-in\n\nFoam is like a bathtub: _What you get out of it depends on what you put into it._\n\n## Getting Started\n\n- [[installation]]\n- [[get-started-with-vscode]]\n- [[recommended-extensions]]\n- [[first-workspace]]\n- [[note-taking-in-foam]]\n- [[sync-notes]]\n- [[keyboard-shortcuts]]\n\n## Features\n\n- [[wikilinks]]\n- [[block-anchors]]\n- [[embeds]]\n- [[foam-queries]]\n- [[tags]]\n- [[backlinking]]\n- [[daily-notes]]\n- [[spell-checking]]\n- [[graph-view]]\n- [[note-properties]]\n- [[templates]]\n- [[paste-images-from-clipboard]]\n- [[custom-markdown-preview-styles]]\n- [[link-reference-definitions]]\n- [[custom-snippets]]\n\n## Recipes\n\n[[recipes]] is a collection of user-contributed patterns that describe different ways you could utilize Foam or integrate it with other tools.\n\n## Publishing\n\nYou can publish your Foam notes for consumption in different formats.\nExamples: [[publish-to-github-pages]], [[generate-gatsby-site]], [[publish-to-vercel]]\n\nSee [[publishing]] for more details.\n\n## Tools\n\n- [[cli]]\n- [[workspace-janitor]]\n- [[orphans]]\n- [[foam-logging-in-vscode]]\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[frequently-asked-questions]: frequently-asked-questions.md \"Frequently Asked Questions\"\n[installation]: getting-started/installation.md \"Installation\"\n[get-started-with-vscode]: getting-started/get-started-with-vscode.md \"Using Foam with VS Code Features\"\n[recommended-extensions]: getting-started/recommended-extensions.md \"Recommended Extensions\"\n[first-workspace]: getting-started/first-workspace.md \"Creating Your First Workspace\"\n[note-taking-in-foam]: getting-started/note-taking-in-foam.md \"Note-Taking in Foam\"\n[sync-notes]: getting-started/sync-notes.md \"Sync notes with source control\"\n[keyboard-shortcuts]: getting-started/keyboard-shortcuts.md \"Keyboard Shortcuts\"\n[wikilinks]: features/wikilinks.md \"Wikilinks\"\n[block-anchors]: features/block-anchors.md \"Block Anchors\"\n[embeds]: features/embeds.md \"Note Embeds\"\n[foam-queries]: features/foam-queries.md \"Foam Queries\"\n[tags]: features/tags.md \"Tags\"\n[backlinking]: features/backlinking.md \"Backlinks\"\n[daily-notes]: features/daily-notes.md \"Daily Notes\"\n[spell-checking]: features/spell-checking.md \"Spell Checking\"\n[graph-view]: features/graph-view.md \"Graph Visualization\"\n[note-properties]: features/note-properties.md \"Note Properties\"\n[templates]: features/templates.md \"Note Templates\"\n[paste-images-from-clipboard]: features/paste-images-from-clipboard.md \"Paste Images from Clipboard\"\n[custom-markdown-preview-styles]: features/custom-markdown-preview-styles.md \"Custom Markdown Preview Styles\"\n[link-reference-definitions]: features/link-reference-definitions.md \"Link Reference Definitions\"\n[custom-snippets]: features/custom-snippets.md \"Adding Custom Snippets\"\n[recipes]: recipes/recipes.md \"Recipes\"\n[publish-to-github-pages]: publishing/publish-to-github-pages.md \"GitHub Pages\"\n[generate-gatsby-site]: publishing/generate-gatsby-site.md \"Generate a site using Gatsby\"\n[publish-to-vercel]: publishing/publish-to-vercel.md \"Publish to Vercel\"\n[publishing]: publishing/publishing.md \"Publishing pages\"\n[cli]: tools/cli.md \"Command Line Interface\"\n[workspace-janitor]: tools/workspace-janitor.md \"Janitor\"\n[orphans]: tools/orphans.md \"Orphaned Notes\"\n[foam-logging-in-vscode]: tools/foam-logging-in-vscode.md \"Foam logging in VsCode\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/publishing/generate-gatsby-site.md",
    "content": "# Generate a site using Gatsby\n\n## Using foam-gatsby-template\n\nYou can use [foam-gatsby-template](https://github.com/mathieudutour/foam-gatsby-template) to generate a static site to host it online on GitHub or [Vercel](https://vercel.com).\n\n### Publishing your foam to GitHub pages\n\nIt comes configured with GitHub actions to auto deploy to GitHub pages when changes are pushed to your main branch.\n\n### Publishing your foam to Vercel\n\nWhen you're ready to publish, run a local build.\n\n```bash\ncd _layouts\nnpm run build\n```\n\nRemove `public` from your .gitignore file then commit and push your public folder in `_layouts` to GitHub.\n\nLog into your Vercel account. (Create one if you don't have it already.)\n\nImport your project. Select `_layouts/public` as your root directory and click **Continue**. Then name your project and click **Deploy**.\n\nThat's it!\n\n## Using foam-template-gatsby-kb\n\nYou can use another template [foam-template-gatsby-kb](https://github.com/hikerpig/foam-template-gatsby-kb), and host it on [Vercel](https://vercel.com) or [Netlify](https://www.netlify.com/).\n\n## Using foam-template-gatsby-theme-primer-wiki\n\nYou can use another template [foam-template-gatsby-theme-primer-wiki](https://github.com/theowenyoung/foam-template-gatsby-theme-primer-wiki), ([Demo](https://demo-wiki.owenyoung.com/)), and host it on Github Pages, [Vercel](https://vercel.com) or [Netlify](https://www.netlify.com/).\n"
  },
  {
    "path": "docs/user/publishing/math-support-with-katex.md",
    "content": "# Katex Math Rendering\n\nApart from using the method mentioned in [[math-support-with-mathjax]], we can also use KaTeX to render our math equations in Foam. The caveat is: we can't rely on GitHub Pages to host and deploy our website anymore, because the plugin we'll be using to let Jekyll support KaTeX doesn't play well together with GitHub Pages.\n\nThe alternative solution is to using [[publish-to-vercel]] for building and publishing our website, so before you start integrating KaTeX into your Foam project, please follow the instructions to host your Foam workspace on [[publish-to-vercel]] first.\n\n## Adding required plugins\n\nAdd the plugin `jekyll-katex` to your Foam workspace's `_config.yml` and `Gemfile` if you haven't done so already. For detailed instructions, please refer to the `#Adding a _config.yml` and `#Adding a Gemfile` in [[publish-to-vercel]].\n\n## Loading KaTeX JS and CSS\n\nBecause we are using KaTeX to render math, we will also need to import KaTeX's JS and CSS files from CDN. The official method to load these files is documented at: [KaTeX/KaTeX#starter-template](https://github.com/KaTeX/KaTeX#starter-template). In our case, we will need to add the following code snippet to our `_layouts/page.html`:\n\n```html\n<!-- _layouts/page.html -->\n---\nlayout: default\n---\n\n<link rel=\"stylesheet\" href=\"https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/katex.min.css\" integrity=\"sha384-AfEj0r4/OFrOo5t7NnNe46zW/tFgW6x/bCJG8FqQCEo3+Aro6EYUG4+cU+KJWu/X\" crossorigin=\"anonymous\">\n\n<!-- The loading of KaTeX is deferred to speed up page rendering -->\n<script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/katex.min.js\" integrity=\"sha384-g7c+Jr9ZivxKLnZTDUhnkOnsh30B4H0rpLUpJ4jAIKs4fnJI+sEnkvrMWph2EDg4\" crossorigin=\"anonymous\"></script>\n\n<!-- To automatically render math in text elements, include the auto-render extension: -->\n<script defer src=\"https://cdn.jsdelivr.net/npm/katex@0.12.0/dist/contrib/auto-render.min.js\" integrity=\"sha384-mll67QQFJfxn0IYznZYonOWZ644AWYC+Pt2cHqMaRhXVrursRwvLnLaebdGIlYNa\" crossorigin=\"anonymous\" onload=\"renderMathInElement(document.body);\"></script>\n\n<!-- ... -->\n```\n\n## Adding liquid tags to wrap page content\n\nThe plugin `jekyll-katex` focuses on rendering:\n\n- Single math equations wrapped inside `katex` liquid tags like {% raw %}`{% katex %} ... {% endkatex %}`{% endraw %}.\n- Or multiple math equations in paragraphs wrapped inside {% raw %}`{% katexmm %} ... {% endkatexmm %}`{% endraw %}.\n\nIn our case, we'll be using the latter tag to wrap our {% raw %}`{{ content }}`{% endraw %}. Wrap {% raw %}`{{ content }}`{% endraw %} in the liquid tags inside `_layouts/page.html` like so:\n\n```html\n<!-- _layouts/page.html -->\n\n<!-- ... -->\n{% raw %}{% katexmm %} {{ content }} {% endkatexmm %}{% endraw %}\n<!-- ... -->\n```\n\n## Render equations in Foam's homepage as well\n\nYou may have noticed that we only made modifications to the template `_layouts/page.html`, which means that `_layouts/home.html` won't have KaTeX support. If you wan't to render math in Foam's home page, you'll need to make the same modifications to `_layouts/home.html` as well.\n\nFinally, if all goes well, then our site hosted on Vercel will support rendering math equations with KaTeX after committing these changes to GitHub. Here's a demo of the default template with KaTeX support: [Foam Template with KaTeX support](https://foam-template.vercel.app/).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[math-support-with-mathjax]: math-support-with-mathjax.md \"Math Support\"\n[publish-to-vercel]: publish-to-vercel.md \"Publish to Vercel\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/publishing/math-support-with-mathjax.md",
    "content": "---\nlayout: mathjax\n---\n\n# Math Support\n\nPublished Foam pages don't support math formulas by default. To enable this feature, you can add the following code snippet to the end of `_layouts/page.html`:\n\n```html\n<script src=\"https://cdn.jsdelivr.net/npm/mathjax@2/MathJax.js?config=TeX-AMS-MML_HTMLorMML\" type=\"text/javascript\"></script>\n<script type=\"text/x-mathjax-config\">\n    MathJax.Hub.Config({\n        tex2jax: {\n            skipTags: ['script', 'noscript', 'style', 'textarea', 'pre'],\n            inlineMath: [['$','$']]\n        }\n    });\n</script>\n```\n\nThis approach uses the [MathJax](https://www.mathjax.org/) library to render anything delimited by ```$``` (customizable in the snippet above) pairs to inline math and ```$$``` to blocks of math (like a html div tag) using with the AMS-LaTeX dialect embedded within MathJax.\n\nExample of inline math using `$...$`:\n\n`$e^{i \\pi}+1=0$`, becomes $e^{i \\pi}+1=0$\n\nExample of a math block using `$$...$$`:\n\n`$$ f_{\\mathbf{X}}\\left(x_{1}, \\ldots, x_{k}\\right)=\\frac{\\exp \\left(-\\frac{1}{2}(\\mathbf{x}-\\boldsymbol{\\mu})^{\\mathrm{T}} \\mathbf{\\Sigma}^{-1}(\\mathbf{x}-\\boldsymbol{\\mu})\\right)}{\\sqrt{(2 \\pi)^{k}|\\mathbf{\\Sigma}|}} $$`\n\nBecomes:\n\n$$ f_{\\mathbf{X}}\\left(x_{1}, \\ldots, x_{k}\\right)=\\frac{\\exp \\left(-\\frac{1}{2}(\\mathbf{x}-\\boldsymbol{\\mu})^{\\mathrm{T}} \\mathbf{\\Sigma}^{-1}(\\mathbf{x}-\\boldsymbol{\\mu})\\right)}{\\sqrt{(2 \\pi)^{k}|\\mathbf{\\Sigma}|}} $$\n\n## Alternative approaches\n\nThere are other dialects of LaTeX (instead of AMS), and other JavaScript rendering libraries you may want to use. In a future version of Foam, we may support KaTeX syntax out of the box, but at this time, these integrations are left as an exercise to the user.\n\n## Why don't my Math expressions work on my Foam's home page?\n\nIf you want the index page of your Foam site to render maths, you'll need to add that to `_layouts/home.html` as well, or change the layout of the index page to be \"page\" instead of \"home\" by putting this Front Matter on the top of your `readme.md/index.md`:\n\n```\n---\nlayout: page\n---\n\n# Your normal title here\n```\n\nReference: [How to support latex in github-pages](https://stackoverflow.com/questions/26275645/how-to-support-latex-in-github-pages)\n"
  },
  {
    "path": "docs/user/publishing/publish-to-azure-devops-wiki.md",
    "content": "# Publish to Azure DevOps Wiki\n\nPublish your Foam workspace as an Azure DevOps wiki.\n\n[Azure DevOps](https://azure.microsoft.com/en-us/services/devops/) is Microsoft's collaboration software for software development teams, formerly known as Team Foundation Server (TFS) and Visual Studio Team Services. It is available as an on-premise or SaaS version. The following recipe was tested with the SaaS version, but should work the same way for the on-premise.\n\nThe following recipe is written with the assumption that you already have an [Azure DevOps](https://azure.microsoft.com/en-us/services/devops/) project.\n\n## Setup a Foam workspace\n\n1. Generate a Foam workspace using the [foam-template project](https://github.com/foambubble/foam-template).\n2. Change the remote to a git repository in Azure DevOps (Repos -> Import a Repository -> Add Clone URL with Authentication), or copy all the files into a new Azure DevOps git repository.\n3. Define which document will be the wiki home page. To do that, create a file called `.order` in the Foam workspace root folder, with first line being the document filename without `.md` extension. For a project created from the Foam template, the file would look like this:\n\n```\nreadme\n```\n\n4. Push the repository to remote in Azure DevOps.\n\n## Publish repository to a wiki\n\n1. Navigate to your Azure DevOps project in a web browser.\n2. Choose **Overview** > **Wiki**. If you don't have wikis for your project, choose **Publish code as a wiki** on welcome page.\n3. Choose repository with your Foam workspace, branch (usually `master` or `main`), folder (for workspace created from foam-template it is `/`), and wiki name, and press **Publish**.\n\nA published workspace looks like this:\n\n![Azure DevOps wiki](../../assets/images/azure-devops-wiki-demo.png)\n\nThere is default table of contents pane to the left of the wiki content. Here, you'll find a list of all directories that are present in your Foam workspace, and all wiki pages. Page names are derived from files names, and they are listed in alphabetical order. You may reorder pages by adding filenames without `.md` extension to `.order` file.\n\n_Note that first entry in `.order` file defines wiki's home page._\n\n## Update wiki\n\nWhile you are pushing changes to GitHub, you won't see the wiki updated if you don't add Azure as a remote. You can push to multiple repositories simultaneously.\n\n1.  First open a terminal and check if Azure is added running: `git remote show origin`. If you don't see Azure add it in the output then follow these steps.\n2.  Rename your current remote (most likely named origin) to a different name by running: `git remote rename origin main`\n3.  You can then add the remote for your second remote repository, in this case, Azure. e.g `git remote add azure https://<YOUR_ID>@dev.azure.com/<YOUR_ID>/foam-notes/_git/foam-notes`. You can get it from: Repos->Files->Clone and copy the URL.\n4.  Now, you need to set up your origin remote to push to both of these. So run: `git config -e` and edit it.\n5.  Add the `remote origin` section to the bottom of the file with the URLs from each remote repository you'd like to push to. You'll see something like that:\n\n```bash\n[core]\n ...\n  (ignore this part)\n  ...\n[branch \"main\"]\n remote = github\n merge = refs/heads/main\n[remote \"github\"]\n url = git@github.com:username/repo.git\n fetch = +refs/heads/*:refs/remotes/github/*\n[remote \"azure\"]\n url = https://<YOUR_ID>@dev.azure.com/<YOUR_ID>/foam-notes/_git/foam-notes\n fetch = +refs/heads/*:refs/remotes/azure/*\n[remote \"origin\"]\n url = git@github.com:username/repo.git\n url = https://<YOUR_ID>@dev.azure.com/<YOUR_ID>/foam-notes/_git/foam-notes\n```\n\n6.  You can then push to both repositories by: `git push origin main` or a single one using: `git push github main` or `git push azure main`\n\nFor more information, read the [Azure DevOps documentation](https://docs.microsoft.com/en-us/azure/devops/project/wiki/publish-repo-to-wiki).\n"
  },
  {
    "path": "docs/user/publishing/publish-to-github-pages.md",
    "content": "# GitHub Pages\n\n1. In VSCode workspace settings set `\"foam.edit.linkReferenceDefinitions\": \"withoutExtensions\"`\n2. Execute the “Foam: Run Janitor” command from the command palette.\n3. [Turn **GitHub Pages** on in your repository settings](https://guides.github.com/features/pages/).\n   - The default GitHub Pages template is called [Primer](https://github.com/pages-themes/primer). See Primer docs for how to customise html layouts and templates.\n   - GitHub Pages is built on [Jekyll](https://jekyllrb.com/), so it supports things like permalinks, front matter metadata etc.\n\n## How to publish locally\n\nIf you want to test your published foam, follow the instructions:\n\n- <https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/creating-a-github-pages-site-with-jekyll>\n- <https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/testing-your-github-pages-site-locally-with-jekyll>\n\nAssuming you have installed ruby/jekyll and the rest:\n\n- `touch Gemfile`\n  - open the file and paste the following:\n\n```\nsource 'https://rubygems.org'\n\ngem \"github-pages\", \"VERSION\"\n```\n\nreplacing `VERSION` with the latest from <https://rubygems.org/gems/github-pages> (e.g. `gem \"github-pages\", \"209\"`)\n\n- `bundle`\n- `bundle exec jekyll 3.9.0 new .`\n- edit the `Gemfile` according to the instructions at [Creating Your Site](https://docs.github.com/en/free-pro-team@latest/github/working-with-github-pages/creating-a-github-pages-site-with-jekyll#creating-your-site) Point n.8\n- `bundle exec jekyll serve`\n\n## Other templates\n\nThere are many other templates which also support publish your foam workspace to github pages\n\n* gatsby-digital-garden\n  * [repo](https://github.com/mathieudutour/gatsby-digital-garden)\n  * [demo-website](https://mathieudutour.github.io/gatsby-digital-garden/)\n* foam-mkdocs-template\n  * [repo](https://github.com/Jackiexiao/foam-mkdocs-template)\n  * [demo-website](https://jackiexiao.github.io/foam/)\n* foam-jekyll-template\n  * [repo](https://github.com/hikerpig/foam-jekyll-template)\n  * [demo-website](https://hikerpig.github.io/foam-jekyll-template/)\n\n[[todo]] [[good-first-task]] Improve this documentation\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: ../../dev/todo.md \"Todo\"\n[good-first-task]: ../../dev/good-first-task.md \"Good First Task\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/publishing/publish-to-github.md",
    "content": "# Publish to GitHub\n\nThe standard [foam-template](https://github.com/foambubble/foam-template) is ready to be published to GitHub, and GitHub pages.\n\n## Enable navigation in GitHub\n\nTo allow navigation from within the GitHub repo, make sure to generate the link references, by setting\n\n- `Foam › Edit: Link Reference Definitions` -> `withExtensions`\n\nSee [[link-reference-definitions]] for more information.\n\n## Customising the style\n\nYou can edit `assets/css/style.scss` to change how published pages look.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[link-reference-definitions]: ../features/link-reference-definitions.md \"Link Reference Definitions\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/publishing/publish-to-gitlab-pages.md",
    "content": "# GitLab Pages\n\nYou don't have to use GitHub to serve Foam pages. You can also use GitLab.\n\nGitlab pages can be kept private for private repo, so that your notes are still private.\n\n## Setup a project\n\n### Generate the directory from GitHub\n\nGenerate a solution using the [Foam template](https://github.com/foambubble/foam-template).\n\nChange the remote to GitLab, or copy all the files into a new GitLab repo\n\n## Publishing pages with Gatsby\n\n### Setup the Gatsby config\n\nAdd a .gatsby-config.js file where:\n\n* `$REPO_NAME` correspond to the name of your gtlab repo.\n* `$USER_NAME` correspond to your gitlab username.\n\n```js\nconst path = require(\"path\");\nconst pathPrefix = `/$REPO_NAME`;\n\n// Change me\nconst siteMetadata = {\n  title: \"A title\",\n  shortName: \"A short name\",\n  description: \"\",\n  imageUrl: \"/graph-visualization.jpg\",\n  siteUrl: \"https://$USER_NAME.gitlab.io\",\n};\nmodule.exports = {\n  siteMetadata,\n  pathPrefix,\n  flags: {\n    DEV_SSR: true,\n  },\n  plugins: [\n    `gatsby-plugin-sharp`,\n    {\n      resolve: \"gatsby-theme-primer-wiki\",\n      options: {\n        defaultColorMode: \"night\",\n        icon: \"./path_to/logo.png\",\n        sidebarComponents: [\"tag\", \"category\"],\n        nav: [\n          {\n            title: \"Github\",\n            url: \"https://github.com/$USER_NAME/\",\n          },\n          {\n            title: \"Gitlab\",\n            url: \"https://gitlab.com/$USER_NAME/\",\n          },\n        ],\n        editUrl:\n          \"https://gitlab.com/$USER_NAME/$REPO_NAME/tree/main/\",\n      },\n    },\n    {\n      resolve: \"gatsby-source-filesystem\",\n      options: {\n        name: \"content\",\n        path: `${__dirname}`,\n        ignore: [`**/\\.*/**/*`],\n      },\n    },\n\n    {\n      resolve: \"gatsby-plugin-manifest\",\n      options: {\n        name: siteMetadata.title,\n        short_name: siteMetadata.shortName,\n        start_url: pathPrefix,\n        background_color: `#f7f0eb`,\n        display: `standalone`,\n        icon: path.resolve(__dirname, \"./path_to/logo.png\"),\n      },\n    },\n    {\n      resolve: `gatsby-plugin-sitemap`,\n    },\n    {\n      resolve: \"gatsby-plugin-robots-txt\",\n      options: {\n        host: siteMetadata.siteUrl,\n        sitemap: `${siteMetadata.siteUrl}/sitemap/sitemap-index.xml`,\n        policy: [{ userAgent: \"*\", allow: \"/\" }],\n      },\n    },\n  ],\n};\n```\n\nAnd a `package.json` file containing:\n\n```json\n{\n    \"private\": true,\n    \"name\": \"wiki\",\n    \"version\": \"1.0.0\",\n    \"license\": \"MIT\",\n    \"scripts\": {\n        \"develop\": \"gatsby develop -H 0.0.0.0\",\n        \"start\": \"gatsby develop -H 0.0.0.0\",\n        \"build\": \"gatsby build\",\n        \"clean\": \"gatsby clean\",\n        \"serve\": \"gatsby serve\",\n        \"test\": \"echo test\"\n    },\n    \"dependencies\": {\n        \"@primer/react\": \"^34.1.0\",\n        \"@primer/css\": \"^17.5.0\",\n        \"foam-cli\": \"^0.11.0\",\n        \"gatsby\": \"^3.12.0\",\n        \"gatsby-plugin-manifest\": \"^3.12.0\",\n        \"gatsby-plugin-robots-txt\": \"^1.6.9\",\n        \"gatsby-plugin-sitemap\": \"^5.4.0\",\n        \"gatsby-source-filesystem\": \"^3.12.0\",\n        \"gatsby-theme-primer-wiki\": \"^1.14.5\",\n        \"react\": \"^17.0.2\",\n        \"react-dom\": \"^17.0.2\"\n    }\n}\n```\n\nThe theme will be based on [gatsby-theme-primer-wiki](https://github.com/theowenyoung/gatsby-theme-primer-wiki).\n\nTo test the theme locally first run `yarn install` and then use `gatsby develop` to serve the website.\nSee gatsby documentation for more details.\n\n### Set-up the CI for deployment\n\nCreate a `.gitlab-ci.yml` file containing:\n\n```yml\n# To contribute improvements to CI/CD templates, please follow the Development guide at:\n# https://docs.gitlab.com/ee/development/cicd/templates.html\n# This specific template is located at:\n# https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/ci/templates/Pages/Gatsby.gitlab-ci.yml\n\nimage: node:latest\n\nstages:\n  - deploy\n\npages:\n  stage: deploy\n  # This folder is cached between builds\n  # https://docs.gitlab.com/ee/ci/yaml/index.html#cache\n  cache:\n    paths:\n      - node_modules/\n      # Enables git-lab CI caching. Both .cache and public must be cached, otherwise builds will fail.\n      - .cache/\n      - public/\n  script:\n    - yarn install\n    - ./node_modules/.bin/gatsby build --prefix-paths\n  artifacts:\n    paths:\n      - public\n  rules:\n    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH\n```\n\nThis pipeline will now serve your website on every push to the main branch of your project.\n\n## Publish with Jekyll\n\n### Add a _config.yaml\n\nAdd another file to the root directory (the one with `readme.md` in it) called `_config.yaml` (no extension)\n\n```yaml\ntitle: My Awesome Foam Project\nbaseurl: \"\" # the subpath of your site, e.g. /blog\nurl: \"/\" # the base hostname & protocol for your site\ntheme: jekyll-theme-minimal\nplugins:\n  - jekyll-optional-front-matter\noptional_front_matter:\n  remove_originals: true\ndefaults:\n  -\n    scope:\n      path: \"\" # we need to add this to properly render layouts\n    values:\n      layout: \"default\"\n```\n\nYou can choose a theme if you want from places like [Jekyll Themes](https://jekyllthemes.io/)\n\n### Add a Gemlock file\n\nAdd another file to the root directory (the one with `readme.md` in it) called `Gemfile` (no extension)\n\n```ruby\nsource \"https://rubygems.org\"\n\ngem \"jekyll\"\ngem \"jekyll-theme-minimal\"\ngem \"jekyll-optional-front-matter\"\n```\n\nCommit the file and push it to gitlab.\n\n### Setup CI/CD\n\n1. From the project home in GitLab click `Set up CI/CD`\n2. Choose `Jekyll` as your template from the template dropdown\n3. Click `commit`\n4. Now when you go to CI / CD > Pipelines, you should see the code running\n\n### Troubleshooting\n\n- *Could not locate Gemfile* - You didn't follow the steps above to [Add a Gemlock file](#add-a-gemlock-file)\n- *Conversion error: Jekyll::Converters::Scss encountered an error while converting* You need to reference a theme.\n- *Pages are running in CI/CD, but I only ever see `test`, and never deploy* - Perhaps you've renamed the main branch (from master) - check the settings in `.gitlab-ci.yml` and ensure the deploy command is running to the branch you expect it to.\n- *I deployed, but my .msd files don't seem to be being converted into .html files* - You need a gem that GitHub installs by default - check `gem \"jekyll-optional-front-matter\"` appears in the `Gemfile`\n"
  },
  {
    "path": "docs/user/publishing/publish-to-netlify-with-eleventy.md",
    "content": "# Publish to Netlify with Eleventy\n\nYou can use [foam-eleventy-template](https://github.com/juanfrank77/foam-eleventy-template) to generate a static site with [Eleventy](https://www.11ty.dev/), and host it online on [Netlify](https://www.netlify.com/).\n\nWith this template you can\n\n- Have control over what to publish and what to keep private\n- Customize the styling of the site to your own liking\n\n## Publishing your foam\n\nWhen you're ready to publish, import the GitHub repository you created with **foam-eleventy-template** into your Netlify account. (Create one if you don't have it already.)\n\nOnce that's done, all you have to do is make changes to your workspace in VS Code and push them to the main branch on GitHub. Netlify will recognize the changes, deploy them automatically and give you a link where your Foam is published.\n\nThat's it!\n\nYou can now see it online and use that link to share it with your friends, so that they can see it too.\n"
  },
  {
    "path": "docs/user/publishing/publish-to-vercel.md",
    "content": "# Publish to Vercel\n\nThis #recipe shows you how to deploy the default Foam website template to Vercel.\n\n[Vercel](https://vercel.com/) is a static website hosting solution similar to GitHub Pages (see [[publish-to-github-pages]]).\n\n## Setting up the project\n\n### Using Foam's template\n\nGenerate a GitHub repository using the default [Foam template](https://github.com/foambubble/foam-template), this will be the workspace that we will be deploying with Vercel. This workspace is a barebone Jekyll source website, which means we can customize and install plugins just like any other Jekyll websites.\n\nAs we won't be using GitHub Pages, we will be adding a few configuration files in order to help Vercel pick up on how to build our site.\n\n### Adding a `_config.yml`\n\nFirst, we'll need to add a `_config.yml` at the root directory. This is the Jekyll configuration file. In here, we will set the site's title, theme, repository and permalink options, and also tell Jekyll what plugins to use:\n\n```yaml\n# _config.yml\ntitle: Foam\n# All the plugins we will be installing now that we won't be using GitHub Pages\nplugins:\n  - jekyll-katex  # optional\n  - jekyll-default-layout\n  - jekyll-relative-links\n  - jekyll-readme-index\n  - jekyll-titles-from-headings\n  - jekyll-optional-front-matter\n# The default Jekyll theme we will be using\ntheme: jekyll-theme-primer\n# The GitHub repository that we are hosting our foam workspace from\nrepository: user/repo\n# Generate permalinks in format specified in: https://jekyllrb.com/docs/permalinks/#built-in-formats\npermalink: pretty\n```\n\nThe `theme` specifies a theme for our deployed Jekyll website. The default GitHub Pages template is called [Primer](https://github.com/pages-themes/primer). See Primer docs for how to customise html layouts and templates. We can also choose a theme if you want from places like [Jekyll Themes](https://jekyllthemes.io/).\n\nThe `plugins` specifies a list of Jekyll plugins that we will be installing in the next section. As we won't be using GitHub Pages, we'll need to install these plugins that GitHub Pages installs for us under the hood.\n\n_If you want to use LaTeX rendered with KaTeX (which is what the plugin `jekyll-katex` does), you can specify it here. And yes, one of the benefits of deploying with Vercel is that we can use KaTeX to render LaTeX! More on: [[math-support-with-katex]]_\n\n### Adding a `Gemfile`\n\nNext up, we'll create another new file called `Gemfile` in the root directory. This is where we will let Vercel know what plugins to install when building our website.\n\nIn our `Gemfile`, we need to specify our Ruby packages:\n\n```ruby\n# Gemfile\nsource \"https://rubygems.org\"\ngem \"jekyll\"\ngem \"kramdown-parser-gfm\"\ngem \"jekyll-theme-primer\"\ngem \"jekyll-optional-front-matter\"\ngem \"jekyll-default-layout\"\ngem \"jekyll-relative-links\"\ngem \"jekyll-readme-index\"\ngem \"jekyll-titles-from-headings\"\ngem \"jekyll-katex\"  # Optional, the package that enables KaTeX math rendering\n```\n\n### Enable math rendering with KaTeX (optional)\n\nBesides adding the plugin `jekyll-katex` in `_config.yml` and `Gemfile`, we'll also have to follow the guides in [[math-support-with-katex]] to let our site fully support using KaTeX to render math equations.\n\n### Committing changes to GitHub repo\n\nFinally, commit the newly created files to GitHub.\n\n## Importing project to Vercel\n\nFirst, import our foam workspace (GitHub repository) to Vercel with [Vercel's _Import Git Repository_](https://vercel.com/import/git). Paste our GitHub repo's url and Vercel will automatically pull and analyze the tool we use to deploy our website. (In our case: Jekyll.)\n\nNext, select the folder to deploy from if prompted. If we are using the default template, then Vercel will default to the root directory of our Foam workspace.\n\nFinally, if all is successful, Vercel will show the detected framework: Jekyll. Press `Deploy` to proceed on publishing our project.\n\n![](../../assets/images/vercel-detect-preset.png)\n\nAnd now, Vercel will take care of building and rendering our foam workspace each time on push. Vercel will publish our site to `xxx.vercel.app`, we can also define a custom domain name for our Vercel website.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[publish-to-github-pages]: publish-to-github-pages.md \"GitHub Pages\"\n[math-support-with-katex]: math-support-with-katex.md \"Katex Math Rendering\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/publishing/publishing.md",
    "content": "# Publishing pages\n\nFoam pages can be published.\n\nTODO add publishing TOC\n\n## Foam site generator?\n\nAnother case of the [[build-vs-assemble]] dilemma. We could provide a better publishing experience by building a bespoke static site generator (or a gatsby plugin) that's aware of Foam conventions (backlinks etc.)\n\nEventually we should probably do it, as that would unlock a huge amount of power, but we should always strive to keep it optional.\n\nAt a bare minimum, Foam repos should remain valid markdown, and should be publishable by any sufficiently complete markdown to html generation tools.\n\nWould be cool if Foam pages could be published. Some ideas here.\n\n- [x] Easymode: Make your GitHub public\n- [x] Static site generator integration, publish from GH actions to GitHub pages / Netlify etc!!!\n  - [ ] Add annotations to pages for setting visibility (many ways to do this)\n    - [ ] Public by default, and `@private` annotations\n    - [ ] Private by default, and `@public` annotations\n    - [ ] Only public `/public` folder, just move a document there, no annotation needed\n    - [ ] More granular access control? Email someone a link with a hash? [Testing](testing.md)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[build-vs-assemble]: ../../dev/build-vs-assemble.md \"Build vs Assemble\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/add-images-to-notes.md",
    "content": "# Add images to your notes\n\nThis #recipe allows you to paste images on to your notes.\n\nVScode (since\n[1.79](https://code.visualstudio.com/updates/v1_79#_copy-external-media-files-into-workspace-on-drop-or-paste-for-markdown))\nnow has the ability to paste images from the clipboard, or drag-and-drop image\nfiles, directly into markdown documents. The file will be created in the\nworkspace, and a link generated in Markdown format. \n\nVSCode settings under `Markdown > Copy Files` and `Markdown > Editor > Drop` can\nbe used to configure where the files get placed in your workspace, how they're\nnamed, how conflicts with existing files are handled, and more.\n"
  },
  {
    "path": "docs/user/recipes/automatic-git-syncing.md",
    "content": "# Automatically Sync with Git\n\nWith this #recipe you can regularly commit and push to git, to keep your repo in always synched.\nYou can also easily manipulate the git history to reduce clutter.\n\n## Required Extensions\n\n- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc)\n\n## Instructions\n\nClick on the extension link above to see how to use it.\n\n__For Foam specific needs, you can add a comment here by following the [[contribution-guide]]__\n\n## Feedback and issues\n\n- Feedback and issues with the extension should be reported to the authors themselves\n- Feedback and issues with the integration of the extension in Foam can be reported in our [issue tracker](https://github.com/foambubble/foam/issues)\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[contribution-guide]: ../../dev/contribution-guide.md \"Contribution Guide\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/automatically-expand-urls-to-well-titled-links.md",
    "content": "# Automatically Expand URLs to Well-Titled Links\n\nWith this #recipe you can convert a link to a fully-formed Markdown link, using the page's title as a display name. Useful for citations and creating link collections.\n\n## Required Extensions\n\n- [Markdown Link Expander](https://marketplace.visualstudio.com/items?itemName=skn0tt.markdown-link-expander) (not included in template)\n\nMarkdown Link Expander will scrape your URL's `<title>` tag to create a nice Markdown-style link.\n\n## Instructions\n\n![Demo](../../assets/images/prettify-links-demo.gif)\n\n1. Highlight desired URL\n2. `Cmd` + `Shift` + `P`\n3. `Expand URL to Markdown`\n4. Profit\n\nTip: If you paste a lot of links, give the action a custom [key binding](https://code.visualstudio.com/docs/getstarted/keybindings)\n\n## Feedback and issues\n\nHave an idea for the extension? [Feel free to share! 🎉](https://github.com/Skn0tt/markdown-link-expander/issues)\n"
  },
  {
    "path": "docs/user/recipes/capture-notes-with-drafts-pro.md",
    "content": "# Capture Notes With Drafts Pro\n\nWith this #recipe you can create notes on your iOS device, which will automatically be imported into Foam.\n\n## Context\n\n* You use [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) to manage your notes\n* You wish to adopt a practice such as [A writing inbox for transient and incomplete notes](https://notes.andymatuschak.org/A%20writing%20inbox%20for%20transient%20and%20incomplete%20notes)\n* You wish to use [Drafts Pro](https://docs.getdrafts.com/) to capture quick notes into your Foam notes from your iOS device\n\n## Other tools\n\n* We assume you are familiar with how to use GitHub (if you are using Foam this is implicit)\n* You have an iOS device with [Drafts](https://getdrafts.com/)\n* You have upgraded to [Drafts Pro](https://docs.getdrafts.com/draftspro) (needed to edit actions).\n\n## Instructions\n\n1. [Create a new action in Drafts](https://docs.getdrafts.com/docs/actions/editing-actions)\n2. Add a single [step](https://docs.getdrafts.com/actions/steps/) of type Script\n3. Edit the script adding the code from the block below\n4. Edit settings at the top of the script to suit your preferences\n5. Set other Action options in Drafts as you wish\n6. Save the Action\n7. In GitHub [create a Personal Access Token](https://github.com/settings/tokens) and give it `repo` scope - make a note of the token\n8. In Drafts create a note\n9. Select the action you created in steps 1-6\n10. On the first run you will need to add the following information:\n    1. your GitHub username\n    2. the repository name of your Foam repo\n    3. the GitHub access token from step 7\n    4. An author name\n11. Check your GitHub repo for a commit\n12. If you are publishing your Foam to the web you may want to edit your publishing configuration to exclude inbox files - as publishing (and method) is a user choice that is beyond the scope of this recipe\n\n## Code for Drafts Action\n\n```javascript\n// adapted from https://forums.getdrafts.com/t/script-step-post-to-github-without-working-copy/3594\n// post to writing inbox in Foam digital garden\n\n/*\n * edit these lines to suit your preferences\n */\nconst inboxFolder = \"inbox/\";   // the folder in your Foam repo where notes are saved. MUST have trailing slash, except for root of repo use ''\nconst requiredTags = ['inbox']; // all documents will have these added in addition to tags from the Drafts app\nconst addLinkToInbox = true;    // true = created note will have link to [[index]], false = no link\nconst addTimeStamp = true;      // true = add a note of capture date/time at foot of note\n\n/*\n * stop editing\n */\n\nconst credential = Credential.create(\"GitHub garden repo\", \"The repo name, and its credentials, hosting your Foam notes\");\ncredential.addTextField(\"username\", \"GitHub Username\");\ncredential.addTextField('repo', 'Repo name');\ncredential.addPasswordField(\"key\", \"GitHub personal access token\");\ncredential.addTextField('author', 'Author');\ncredential.authorize();\n\nconst githubKey = credential.getValue('key');\nconst githubUser = credential.getValue('username');\nconst repo = credential.getValue('repo');\nconst author = credential.getValue('author');\n\nconst http = HTTP.create(); // create HTTP object\nconst base = 'https://api.github.com';\n\n\nconst posttime = new Date();\nconst title = draft.title;\nconst txt = draft.processTemplate(\"[[line|3..]]\");\nconst mergedTags = [...draft.tags, ...requiredTags];\nconst slugbase = title.toLowerCase().replace(/\\s/g, \"-\");\n\nconst datestr = `${posttime.getFullYear()}-${pad(posttime.getMonth() + 1)}-${pad(posttime.getDate())}`;\nconst timestr = `${pad(posttime.getHours())}:${pad(posttime.getMinutes())}:00`;\nconst yr = `${posttime.getFullYear()}`;\nconst pdOffset = posttime.getTimezoneOffset();\nconst offsetChar = pdOffset >= 0 ? '-' : '+';\nvar pdHours = Math.floor(pdOffset/60);\nconsole.log(pdHours);\npdHours = pdHours >= 0 ? pdHours : pdHours * -1;\nconsole.log(pdHours);\nconst tzString = `${offsetChar}${pad(pdHours)}:00`;\nconst postdate = `${datestr}T${timestr}${tzString}`;\n\n\nconst slug = `${slugbase}`\nconst fn = `${slug}.md`;\nlet preamble = `# ${title} \\n\\n`;\n\nmergedTags.forEach(function(item,index){\n   preamble += `#${item} `;\n  }\n);\n\nif (addLinkToInbox) {\n    preamble += \"\\n\\n[[inbox]]\\n\";\n}\n\npreamble += \"\\n\\n\";\n\nvar doc = `${preamble}${txt}`;\n\nif (addTimeStamp){\n\n    doc += `\\n\\nCaptured: ${postdate}\\n`\n}\n\nconst options = {\n    url: `https://api.github.com/repos/${githubUser}/${repo}/contents/${inboxFolder}${fn}`,\n    method: 'PUT',\n    data: {\n        message: `Inbox from Drafts ${datestr}`,\n        content: Base64.encode(doc)\n    },\n    headers: {\n        'Authorization': `token ${githubKey}`\n    }\n};\n\nvar response = http.request(options);\n\nif (response.success) {\n    // yay\n} else {\n    console.log(response.statusCode);\n    console.log(response.error);\n}\n\nfunction pad(n) {\n    let str = String(n);\n    while (str.length < 2) {\n        str = `0${str}`;\n    }\n    return str;\n}\n\n```\n"
  },
  {
    "path": "docs/user/recipes/capture-notes-with-shortcuts-and-github-actions.md",
    "content": "# Capture Notes With Shortcuts and GitHub Actions\n\nWith this #recipe you can create notes on your iOS device, which will automatically be imported into Foam.\n\n## Context\n\n* You use [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) to manage your notes\n* You wish to adopt a practice such as [A writing inbox for transient and incomplete notes](https://notes.andymatuschak.org/A%20writing%20inbox%20for%20transient%20and%20incomplete%20notes)\n* You wish to use [Shortcuts](https://support.apple.com/guide/shortcuts/welcome/ios) to capture quick notes into your Foam notes from your iOS device\n\n## Other tools\n\n* We assume you are familiar with how to use GitHub (if you are using Foam this is implicit)\n* You have an iOS device.\n\n## Instructions\n\n1. Setup the [`foam-capture-action`]() in your GitHub Repository, to be triggered by \"Workflow dispatch\" events.\n\n```\nname: Manually triggered workflow\non:\n  workflow_dispatch:\n    inputs:\n      data:\n        description: 'What information to put in the knowledge base.'\n        required: true\n\njobs:\n  store_data:\n    runs-on: ubuntu-latest\n    # If you encounter a 403 error from a workflow run, try uncommenting the following 2 lines (taken from: https://stackoverflow.com/questions/75880266/cant-make-push-on-a-repo-with-github-actions accepted answer)\n    # permissions:\n          # contents: write\n    steps:\n    - uses: actions/checkout@master\n    - uses: anglinb/foam-capture-action@main\n      with:\n        {% raw %}\n        capture: ${{ github.event.inputs.data }}\n        {% endraw %}\n    - run: |\n        git config --local user.email \"example@gmail.com\"\n        git config --local user.name \"Your name\"\n        git commit -m \"Captured from workflow trigger\" -a\n        git push -u origin master\n```\n\n2. In GitHub [create a Personal Access Token](https://github.com/settings/tokens) and give it `repo` scope - make a note of the token\n3. Run this command to find your `workflow-id` to be used in the Shortcut.\n\n```bash\ncurl \\\n  -H \"Accept: application/vnd.github.v3+json\" \\\n  -H \"Authorization: Bearer <GITHUB_TOKEN>\" \\\n    https://api.github.com/repos/<owner>/<repository>/actions/workflows\n```\n\n4. Copy this [Shortcut](https://www.icloud.com/shortcuts/57d2ed90c40e43a5badcc174ebfaaf1d) to your iOS devices and edit the contents of the last step, `GetContentsOfURL`\n   - Make sure you update the URL of the shortcut step with the `owner`, `repository`, `workflow-id` (from the previous step)\n   - Make sure you update the headers of the shortcut step, replaceing `[GITHUB_TOKEN]` with your Personal Access Token (from step 2)\n\n5. Run the shortcut & celebrate! ✨ (You should see a GitHub Action run start and the text you entered show up in `inbox.md` in your repository.)\n"
  },
  {
    "path": "docs/user/recipes/diagrams-in-markdown.md",
    "content": "# Diagrams in Markdown\n\nWe have two alternative #recipe for displaying diagrams in markdown:\n\n- [Diagrams in Markdown](#diagrams-in-markdown)\n  - [Mermaid](#mermaid)\n  - [Draw.io](#drawio)\n    - [Using Draw.io](#using-drawio)\n\n## Mermaid\n\nYou can use [Mermaid](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid) plugin to draw and preview diagrams in your content.\n\n## Draw.io\n\n[Draw.io](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio) extension allows you to create, edit, and display your diagrams without leaving Visual Studio Code. The `.drawio.svg` or `.drawio.png` files can be automatically embedded and displayed in published Foams, no export needed. FYI, the diagram below was made using Draw.io! You can check the diagram [here](../../assets/images/diagram-drawio-demo.drawio.svg).\n\n![diagram-drawio-demo](../../assets/images/diagram-drawio-demo.drawio.svg)\n\n### Using Draw.io\n\n1. Install [Draw.io](https://marketplace.visualstudio.com/items?itemName=hediet.vscode-drawio) VS Code extension.\n2. Create a new `*.drawio.svg` or `*.drawio.png` file.\n3. Start drawing your diagram. Once you done, save it.\n4. Embed the diagram file as you embedding the image file, for example: `![My Diagram](my-diagram.drawio.svg)`\n"
  },
  {
    "path": "docs/user/recipes/export-to-pdf.md",
    "content": "# Export to PDF\n\nThis #recipe shows how to export a note to PDF.\n\n## Required extensions\n\n- **[vscode-pandoc](https://marketplace.visualstudio.com/items?itemName=chrischinchilla.vscode-pandoc)**\n\n## Required third-party tools\n\n- [Pandoc](https://pandoc.org/installing.html)\n- A [LaTeX distribution](https://www.latex-project.org/get/) such as TeXLive (Linux), MacTeX (MacOS), or MikTeX (Windows)\n\nCheck that Pandoc is installed by opening a terminal and running `pandoc --version`.\n\nCheck that Pandoc can produce PDFs with LaTeX by running the following in the terminal.\n\n```\necho It is working > test.md\npandoc test.md -o test.pdf\n```\n\n## Instructions\n\n1. Create a folder in your workspace named `.pandoc`. Take note of the full path to this directory. The rest of this recipe will refer to this path as `$WORKSPACE/.pandoc`.\n\n2. Download the template file [`foam.latex`](https://raw.githubusercontent.com/Hegghammer/foam-templates/main/foam.latex) from [Hegghammer/foam-templates](https://github.com/Hegghammer/foam-templates) and place it in `$WORKSPACE/.pandoc`.\n\n3. In VSCode, open `settings.json` for your user (or just for your workspace if you prefer), and add the following line:\n\n```\n\"pandoc.pdfOptString\": \"--from=markdown+wikilinks_title_after_pipe --resource-path $WORKSPACE/.pandoc --template foam --listings\",\n```\n\nMake sure to replace `$WORKSPACE/.pandoc` with the real full path to the `.pandoc` directory you created earlier.\n\n4. Open a Foam note in VSCode.\n\n5. Press `Ctrl` + `k`, `p`. Choose \"pdf\", and press `Enter`.\n\nThe PDF should look something like this:\n\n![Sample PDF output](../../assets/images/pdf_output.png)\n\n## Options\n\nIf you include a name in the `author` parameter in the YAML of the Foam note, that name will feature in the PDF header on the top left.\n\nIf you don't want syntax highlighting and frames around the codeblocks, remove `--listings` from the `pandoc.pdfOptString` parameter in `settings.json`.\n\n## Further customization\n\nIf you know some LaTeX, you can [tweak](https://bookdown.org/yihui/rmarkdown-cookbook/latex-template.html) the `foam.latex` template to your needs. Alternatively, you can supply another ready-made template such as [Eisvogel](https://github.com/Wandmalfarbe/pandoc-latex-template); just place the `TEMPLATE_NAME.latex` file in `$WORKSPACE/.pandoc`. You can also use all of Pandoc's [other functionalities](https://learnbyexample.github.io/customizing-pandoc/) by tweaking the `pandoc.pdfOptString` parameter in `settings.json`.\n"
  },
  {
    "path": "docs/user/recipes/generate-material-for-mkdocs-site.md",
    "content": "# Generate a site using the Material for MkDocs theme\n\nConfiguring a static-site generator (SSG) to publish your Foam provides access to functionality not available through Foam's default publishing mechanism.  For example, compare the [original Foam documentation site](https://foambubble.github.io/foam/) with a [Material for MkDocs version](https://djplaner.github.io/foam-with-material-for-mkdocs/) created using the simple configuration detailed below. Try out the search functionality on the Material for MkDocs version. This [digital garden](https://djon.es/memex) and this [blog](https://djon.es/blog/) provide more advanced examples of Foam content published using Material for MkDocs.\n\nThe following explains how to configure the [Material for MkDocs theme](https://squidfunk.github.io/mkdocs-material/) for the [MkDocs SSG](https://www.mkdocs.org) to publish your Foam.\n\nLike most SSGs (e.g. [Gatsby](https://www.gatsbyjs.com/) is another SSG that can be [used to publish your Foam](https://foambubble.github.io/foam/user/publishing/generate-gatsby-site)) site content is accepted in the form of Markdown files. Like those produced by Foam. SSGs differ in the languages they are written in (MkDocs is Python, Gatsby is Javascript and React) and the features they provide. MkDocs and Material for MkDocs are designed to support project documentation.  Gatsby is more general purpose and provides a nice feature set.\n\nYou choose your poison. \n\n## Requirements\n\nTo use Material for MkDocs to publish your Foam you need:\n\n- An existing Foam workspace with content.\n- [Python installed on your computer](https://realpython.com/installing-python/).\n- Some familiarity and comfort with using the command line on your computer.\n\n## Instructions\n\nConfiguring Material for MkDocs to publish your Foam involves the following steps:\n\n1. [Install Material for MkDocs and other requirements](#install-material-for-mkdocs-and-other-requirements).\n\n    Install the Material for MkDocs theme, MkDocs, and other required Python modules. \n\n2. [Configure Material for MkDocs for your Foam](#configure-material-for-mkdocs-for-your-foam).\n\n    Create a `mkdocs.yml` file in the root of your Foam workspace directory. This file configures Material for MkDocs to work with your Foam.\n\n2. [Preview and test your site locally](#preview-and-test-your-site-locally).\n\n    Run MkDocs to preview and test your Material for MkDocs Foam site locally. Good for testing and local use.\n\n3. [Further customise Material for MkDocs](#further-customise-material-for-mkdocs).\n\n    Explore and leverage the additional configuration settings, possible customisations, and additional themes and plugins to customise your site to your needs.\n\n4. [Publish your site](#publish-your-site).\n\n    Publish your Material for MkDocs Foam site to the web for others to enjoy. There are many options for publishing your site, including GitHub, GitLab, Netlify, and others.\n\n### Install Material for MkDocs and other requirements\n\nMaterial for MkDocs provides [detailed installation instructions](https://squidfunk.github.io/mkdocs-material/getting-started/) which cover the full range of options for installing and configuring Material for MkDocs. The following is a summary of the recommended process.\n\n1. Within your Foam workspace directory, create a [Python virtual environment](https://realpython.com/what-is-pip/#using-pip-in-a-python-virtual-environment)\n\n    - `python -m venv .venv`\n    - `source .venv/bin/activate` (Linux/Mac) or `.venv\\Scripts\\activate` (Windows)\n\n2. Install Material for MkDocs\n\n    - `pip install mkdocs-material`\n\n3. Install additional Python modules\n\n    - `pip install mkdocs-roamlinks-plugin` \n    - `pip install mkdocs-exclude` \n\n### Configure Material for MkDocs for your Foam\n\nTo configure Material for MkDocs for your Foam workspace, create a `mkdocs.yml` file in the root of your Foam workspace directory. Below you will find a sample `mkdocs.yml` file (adapted from the [foam-mkdocs-template repository](https://github.com/Jackiexiao/foam-mkdocs-template/tree/master)). Copy and paste it into your `mkdocs.yml` file, then edit it to suit your needs. In particular, don't forget to change the `site_name` and `site_url` to match your Foam workspace. Though this can be left a little later.\n\nMaterial for MkDocs provides documentation on both [minimal](https://squidfunk.github.io/mkdocs-material/creating-your-site/#minimal-configuration) and [advanced](https://squidfunk.github.io/mkdocs-material/creating-your-site/#advanced-configuration) configuration of `mkdocs.yml`. Which are revisited in the [customise section below](#further-customise-your-site)\n\n```yaml\nsite_name: My site # Change this to your site name\nsite_url: https://mydomain.org/mysite # change this\ntheme:\n  name: material\n  features:\n    - navigation.expand \n    - tabs \nmarkdown_extensions: \n  - attr_list \n  - pymdownx.tabbed\n  - nl2br\n  - toc:\n      permalink: '#' \n      slugify: !!python/name:pymdownx.slugs.uslugify \n  - admonition\n  - codehilite:\n      guess_lang: false\n      linenums: false\n  - footnotes\n  - meta\n  - def_list\n  - pymdownx.arithmatex\n  - pymdownx.betterem:\n      smart_enable: all\n  - pymdownx.caret\n  - pymdownx.critic\n  - pymdownx.details\n  - pymdownx.inlinehilite\n  - pymdownx.magiclink\n  - pymdownx.mark\n  - pymdownx.smartsymbols\n  - pymdownx.superfences\n  - pymdownx.tasklist\n  - pymdownx.tilde\nplugins:\n  - search\n  - roamlinks \n  - exclude:\n      glob:\n        - \"*.tmp\"\n        - \"*.pdf\"\n        - \"*.gz\"\n      regex:\n        - '.*\\.(tmp|bin|tar)$'\n```\n\n### Preview and test your site locally\n\nMkDocs provides a live preview server allowing you to preview and test your Material for MkDocs Foam site. The server will continue to rebuid your site as you write. \n\nThe simplest method to use the preview service is to run the following command whilst in the rood directory of your Foam workspace:\n\n```bash\nmkdocs serve\n```\n\nSee the Material for MkDocs site for more, including [how to run the preview server via docker](https://squidfunk.github.io/mkdocs-material/creating-your-site/#previewing-as-you-write)\n\n### Further customise your site\n\nFurther customisation is available through expanding the configuration of Material for MkDocs, using additional MkDocs plugins, customising HTML/CSS, using Markdown extensions, writing your own Python scripts, and more.\n\nFor more on the available customisation paths, see the following:\n\n- Material for MkDocs [Advanced configuration](https://squidfunk.github.io/mkdocs-material/creating-your-site/#advanced-configuration) or the [Set up section](https://squidfunk.github.io/mkdocs-material/setup/)\n\n    For more configuration options to be included in your `mkdocs.yml` file, including customising: colours, fonts, language, icons, navigation, header, footer etc.\n\n- Material for MkDocs [Customisation](https://squidfunk.github.io/mkdocs-material/customization/)\n\n    For advice on enhancing the visual design of your site by customising and replacing provided HTML, CSS, and Javascript.\n\n- Material for MkDocs [Reference](https://squidfunk.github.io/mkdocs-material/reference/)\n\n    An overview of customisation methods that can be used directly within your Markdown files, including: admonitions, annotations, buttons, code blocks, content tabs, data tables, diagrams, grids, Mathematics, etc.\n\n- a [catalog of 300 MkDocs projects and plugins](https://github.com/mkdocs/catalog#readme) \n\n    For functionality and ideas not included in Material for MkDocs, including: additional themes, plugins, and extensions.\n\n### Building and publishing your site\n\nAs a Static Site Generator (SSG), MkDocs generates a collection of static HTML and other types of files. Publishing your site involves building those HTML files and placing them onto your web server. The method will vary depending on your web server and hosting provider. \n\nThe MkDocs documentation site provides an explanation of the [simplest method to publish your site to any provider](https://www.mkdocs.org/user-guide/deploying-your-docs/#other-providers) using `mkdocs build` and `scp`.\n\nThe Material for MkDocs [publish page](https://squidfunk.github.io/mkdocs-material/publishing-your-site/) lists options for publishing to\n\n- GitHub using [mkdocs](https://squidfunk.github.io/mkdocs-material/publishing-your-site/#with-mkdocs)\n\n    Perhaps the simplest method, if you are already using GitHub to host your Foam workspace.\n\n- GitHub using [GitHub actions](https://squidfunk.github.io/mkdocs-material/publishing-your-site/github-actions/)\n\n    A more automated method of publishing your site to GitHub, using GitHub actions.\n\n- [GitLab](https://squidfunk.github.io/mkdocs-material/publishing-your-site/#with-mkdocs)\n\n- [Cloudflage pages](https://deborahwrites.com/guides/deploy-host-mkdocs/deploy-mkdocs-material-cloudflare/)\n\n- [Netlify](https://deborahwrites.com/guides/deploy-host-mkdocs/deploy-mkdocs-material-netlify/)\n\n- [Fly.io](https://documentation.breadnet.co.uk/cloud/fly/mkdocs-on-fly/#prerequisites)\n\n- [Scaleway](https://www.scaleway.com/en/docs/tutorials/using-bucket-website-with-mkdocs/)\n\n"
  },
  {
    "path": "docs/user/recipes/how-to-write-recipes.md",
    "content": "# How to Write Recipes\n\nThis is an example of how to structure a Recipe. The first paragraph or two should explain the purpose of the recipe succinctly, including why it's useful, if that's not obvious.\n\nRecipes are intended to document:\n\n- How to use Foam's basic features\n- Power user pro-tips\n- Useful customisations of the default Foam environment\n- Integrations with third party tools and extensions (should be listed below)\n\n## Required Extensions\n\n- **[Hacker Typer](https://marketplace.visualstudio.com/items?itemName=jevakallio.vscode-hacker-typer)** (not really required for this recipe, just an example)\n- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) (installed by default)\n\nThe first section should be a bulleted list of extensions required to use this recipe. At a minimum, this section should list all additional, non-standard extensions.\n\nIdeally, you should also note which Foam [[recommended-extensions]] are responsible for providing this feature, so any issue reports can be directed to the correct repositories.\n\nWhen creating new recipes, if you don't know which extension does what, you can leave it out.\n\n## Instructions\n\nHere we describe how the extension should be used.\n\n![Demo](../../assets/images/foam-navigation-demo.gif)\n\nYou may include a screenshot or GIF of the feature in action by uploading an image to the `assets/images` directory. Please try to keep GIFs as small as possible by recording them with a low frame rate.\n\nThat's pretty much it!\n\n## How to contribute\n\nYou can add [[recipes]] by creating a pull request to [foambubble/foam](https://github.com/foambubble/foam) on GitHub.\n\nRead more in our [[contribution-guide]].\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[recommended-extensions]: ../getting-started/recommended-extensions.md \"Recommended Extensions\"\n[recipes]: recipes.md \"Recipes\"\n[contribution-guide]: ../../dev/contribution-guide.md \"Contribution Guide\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/markup-converter.md",
    "content": "# Markup Converter\n\nThis #recipe allows you to convert any document into Markdown for storing them in your notes.\n\nWe will be using [Pandoc](https://pandoc.org/), a popular universal document converter. It can convert documents in Microsoft Word, HTML, LaTeX, and many other formats to various formats including markdown and many others.\n\n## Instructions\n\nWe will go through the example of converting Microsoft Word documents to Markdown. For detailed instructions on how to use Pandoc, please refer to the [Pandoc documentation](https://pandoc.org/MANUAL.html).\n\n1. [Install Pandoc](https://pandoc.org/installing.html)\n1. Open the terminal of your choice and verify that Pandoc is installed by running `pandoc --version`\n1. Copy the Microsoft Word documents that you want to convert into a new folder\n1. Change the current directory to the folder containing the Microsoft Word documents\n1. Copy one of the following commands (based on your operating system) into your terminal and press `Enter` to run\n\n### Linux and macOS (Bash)\n\n```bash\nfind -name \"*.docx\" -type f -exec sh -c '\n      for f; do\n         pandoc --extract-media=./ -f docx -t markdown -o \"${f%.*}.md\" \"$f\"\n      done\n   ' find-sh {} +\n```\n\n### Windows (PowerShell)\n\n```powershell\nGet-ChildItem . -Filter *.docx | \nForeach-Object {\n    pandoc --extract-media=./ --from docx --to markdown $_ -o $_.Name.Replace('.docx', '.md')\n}\n```\n\n### Relevant Configurations\n\n[Pandoc](https://pandoc.org/) accepts a range of command line arguments to control the conversion process. Here, we'll mention a few that are relevant to the example above.\n\n- `--extract-media=./` is used to extract the images from the Microsoft Word documents and store them in a subfolder named `media`\n- `-t markdown` converts the Microsoft Word documents to [Pandoc’s Markdown](https://pandoc.org/MANUAL.html#pandocs-markdown). You can also use `-t gfm` to convert to [GitHub Flavored Markdown](https://docs.github.com/en/get-started/writing-on-github)\n\nNote that you may want to review the converted Markdown files to ensure that the conversion was successful. Then, You may want to delete the original Microsoft Word documents.\n"
  },
  {
    "path": "docs/user/recipes/migrating-from-obsidian.md",
    "content": "# Migrating from Obsidian (stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: ../../dev/todo.md \"Todo\"\n[roadmap]: ../../dev/proposals/roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/migrating-from-onenote.md",
    "content": "# Migrating from OneNote\n\nThis guide mostly duplicates the instructions at the repo for the PowerShell [script](https://github.com/nixsee/ConvertOneNote2MarkDown).\n\n## Summary\n\nThe powershell script 'ConvertOneNote2MarkDown-v2.ps1' will utilize the OneNote Object Model on your workstation to convert all OneNote pages to Word documents and then utilizes PanDoc to convert the Word documents to Markdown (.md) format. It will also:\n\n* Create a folder structure for your Notebooks and Sections.\n* Process pages that are in sections at the Notebook, Section Group and 1st Nested Section Group levels.\n* Allow you you choose between putting all Images in a central '/media' folder for each notebook, or in a separate '/media' folder in each folder of the hierarchy.\n* Fix image references in the resulting .md files, generating relative references to the image files within the markdown document.\n* A title, description, and date header will be added to each file as well.\n* And more (see details at repo)!\n\n## Usage\n\n1. Start the OneNote application. All notebooks currently loaded in [OneNote](https://getonetastic.com/download) will be converted.\n2. It is advised that you install [Onetastic](https://getonetastic.com/download) and the attached macro, which will automatically expand any collapsed paragraphs in the notebook. They won't be exported otherwise.\n    * To install the macro, click the New Macro Button within the Onetastic Toolbar and then select File -> Import and select the .xml macro included in the release.\n    * Run the macro for each Notebook that is open\n3. For the next sections, it is highly recommended that you use VS Code, and its embedded PowerShell terminal, as this allows you to edit and run the script, as well as check the results of the .md output all in one window.\n4. Whatever you choose, you will need to do the following:\n   1. Clone the script to your computer (see [here](https://git-scm.com/book/en/v2/Git-Basics-Getting-a-Git-Repository), if you're unfamiliar with git).\n   2. Once cloned, navigate to the repo folder. In VS Code, use File -> Add Folder to Workspace, right click on the folder in the left side bar and click [Open In Integrated Terminal](../../assets/images/migrating-one-note.png).\n   3. Run the script by executing\n```.\\ConvertOnenote2Markdown-v2```\n    * if you receive an error, try running this line to bypass security:\n     ```Set-ExecutionPolicy Bypass -Scope Process```\n    * if you still have trouble, try running both Onenote and Powershell as an administrator.\n5. It will ask you for the path to store the markdown folder structure. Please use an empty folder. If using VS Code, you might not be able to paste the filepath - right click on the blinking cursor and it will paste from clipboard. **Attention:** use a full absolute path for the destination.\n6. Read the prompts carefully to select your desired options. If you aren't actively editing your pages in Onenote, it is HIGHLY recommended that you don't delete the intermediate word docs, as they take 80+% of the time to generate. They are stored in their own folder, out of the way. You can then quickly re-run the script with different parameters until you find what you like.\n7. Sit back and wait until the process completes.\n8. To stop the process at any time, press Ctrl+C.\n9. If you like, you can inspect some of the .md files prior to completion. If you're not happy with the results, stop the process, delete the .md and re-run with different parameters.\n10. At this point, you should be ready to load the new directory into Foam!\n"
  },
  {
    "path": "docs/user/recipes/migrating-from-roam.md",
    "content": "# Migrating from Roam (stub)\n\n**[[todo]] This [[roadmap]] item needs more specification work.**\n\nIf you're interested in working on it, please start a conversation in [GitHub issues](https://github.com/foambubble/foam/issues).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: ../../dev/todo.md \"Todo\"\n[roadmap]: ../../dev/proposals/roadmap.md \"Roadmap\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/predefined-user-snippets.md",
    "content": "# Pre-defined User Snippets\n\nThis #recipe allows us to introduce Roam style commands to Foam, by using [VS Code Snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets). Consider the below snippets:\n\n```json\n{\n  \"Zettelkasten Id\": {\n    \"scope\": \"markdown\",\n    \"prefix\": \"/id\",\n    \"description\": \"Zettelkasten Id\",\n    \"body\": [\n      \"${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}\"\n    ]\n  },\n  \"Current date\": {\n    \"scope\": \"markdown\",\n    \"prefix\": \"/date\",\n    \"description\": \"Current date\",\n    \"body\": [\n      \"${CURRENT_YEAR}-${CURRENT_MONTH}-${CURRENT_DATE} ${CURRENT_HOUR}:${CURRENT_MINUTE}:${CURRENT_SECOND}\"\n    ]\n  }\n}\n```\n\nWhich would look like:\n![GIF demonstrating User Snippets](../../assets/images/snippets.gif)\n\nUsing snippets enables Foam users to add [[custom-snippets]] themselves so they live alongside the first-class `/commands`.\n\n## Notes & Considerations\n\n- VS Code supplies \"commands\" already via the command palette\n  - Consider the UX around this. Users less familiar with VS Code are more likely to be familiar with `/` to trigger a command menu. Experienced VS Code users may be more likely to favour the command palette.\n- We can use `TabCompletionProvider` and `snippets` and mix them (maybe) via the following VS Code setting: `\"editor.snippetSuggestions\": \"inline\" | \"top\" | \"bottom\" | \"none\"`\n- For more discussion, consult the PR [here](https://github.com/foambubble/foam/pull/192).\n\n## Simplifying Markdown Syntax\n\nSome markdown syntax is difficult for users who have never authored markdown before. Take for example a checkbox/todo. The following syntax is required:\n\n```\n- [ ] Something todo...\n```\n\nWe could provide snippets that expand out into the associated markdown syntax, like in the below GIF:\n![GIF demonstrating markdown snippets](../../assets/images/markdown-snippets.gif)\n\nThe JSON for these snippets can be found [here](https://github.com/foambubble/foam/pull/192#issuecomment-666736270).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[custom-snippets]: ../features/custom-snippets.md \"Adding Custom Snippets\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/real-time-collaboration.md",
    "content": "# Real-time Collaboration\n\nThis #recipe is here to just tell you that VS Code Live Share will allow you to collaborate live on your notes.\n"
  },
  {
    "path": "docs/user/recipes/recipes.md",
    "content": "<!-- omit in toc -->\n\n# Recipes\n\nA #recipe is a guide, tip or strategy for getting the most out of your Foam workspace!\n\n- [Recipes](#recipes)\n  - [Contribute](#contribute)\n  - [Take smart notes](#take-smart-notes)\n  - [Discover](#discover)\n  - [Organise](#organise)\n  - [Write](#write)\n  - [Version control](#version-control)\n  - [Publish](#publish)\n  - [Collaborate](#collaborate)\n  - [Workflow](#workflow)\n  - [Creative ideas](#creative-ideas)\n  - [Other](#other)\n\n## Contribute\n\n- Start by reading [[contribution-guide]]\n- If you discover features not listed here, we'd love to have them! [[how-to-write-recipes]].\n\n## Take smart notes\n\n- Introduction to Zettelkasten [[todo]]\n- Clip webpages with [[web-clipper]]\n- Convert Microsoft Word files into Markdown with [[markup-converter]]\n\n## Discover\n\n- Explore your notes using [[graph-view]]\n- Discover relationships with [[backlinking]]\n- Simulating [[unlinked-references]]\n\n## Organise\n\n- Using [[backlinking]] for reference lists.\n\n## Write\n\n- Link documents with [[wikilinks]].\n- Create notes with custom [[commands]] and key bindings\n- Instantly create and access your [[daily-notes]]\n- Add and explore [[tags]]\n- Create [[templates]]\n- Find [[orphans]]\n- Draw [[diagrams-in-markdown]]\n- Prettify your links, [[automatically-expand-urls-to-well-titled-links]]\n- Style your environment with [[custom-markdown-preview-styles]]\n- Paste and link [[add-images-to-notes]]\n- [[shows-image-preview-on-hover]]\n- [Markdown All-in-One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one) features [[todo]] [[good-first-task]]\n  - Manage checklists\n  - Automatic Table of Contents\n  - Live preview markdown\n  - _More..._\n- VS Code Advanced Features [[todo]] [[good-first-task]]\n  - Focus with Zen Mode\n- Display content of other notes in the preview tab by [[embeds]]\n\n## Version control\n\n- Quick commits with VS Code's built in [[git-integration]]\n- Store your workspace in an auto-synced GitHub repo with [[write-your-notes-in-github-gist]]\n- Sync your GitHub repo automatically using the [GitDoc VSCode Plugin](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc) [[automatic-git-syncing]].\n\n## Publish\n\n- Publish using official Foam template\n  - Publish to [[publish-to-github-pages]]\n  - Publish to [[publish-to-gitlab-pages]]\n  - Publish to [[publish-to-azure-devops-wiki]]\n  - Publish to [[publish-to-vercel]]\n- Publish using community templates\n  - [[publish-to-netlify-with-eleventy]] by [@juanfrank77](https://github.com/juanfrank77)\n  - [[generate-gatsby-site]] by [@mathieudutour](https://github.com/mathieudutour) and [@hikerpig](https://github.com/hikerpig)\n  - [[generate-material-for-mkdocs-site]] by [@djplaner](https://github.com/djplaner)\n- Make the site your own by [[publish-to-github]].\n- Render math symbols, by either\n  - adding client-side [[math-support-with-mathjax]] to the default [[publish-to-github-pages]] site\n  - adding a custom Jekyll plugin to support [[math-support-with-katex]]\n- Export note to PDF [[export-to-pdf]]\n\n## Collaborate\n\n- Give your team push access to your [GitHub repo](https://docs.github.com/en/account-and-profile/setting-up-and-managing-your-personal-account-on-github/managing-access-to-your-personal-repositories/inviting-collaborators-to-a-personal-repository)\n- Real-time collaboration via VS Code Live Share [[real-time-collaboration]]\n- Accept patches via GitHub PRs [[todo]]\n\n## Workflow\n\n- Capture notes from Drafts app on iOS [[capture-notes-with-drafts-pro]]\n- Capture notes from iOS Shortcuts [[capture-notes-with-shortcuts-and-github-actions]]\n\n## Creative ideas\n\nCreative ideas welcome!\n\n- Support [Anki](https://apps.ankiweb.net/) cards from notes like [Remnote](https://www.remnote.io/) [[todo]]\n\n_See [[contribution-guide]] and [[how-to-write-recipes]]._\n\n## Other\n\nThought of a recipe but don't see a category for them? Add them here and we'll organise them once we detect a theme.\n\n_See [[contribution-guide]] and [[how-to-write-recipes]]._\n\n[contribution-guide]: ../../dev/contribution-guide.md \"Contribution Guide\"\n[how-to-write-recipes]: how-to-write-recipes.md \"How to Write Recipes\"\n[todo]: ../../dev/todo.md \"Todo\"\n[web-clipper]: web-clipper.md \"Web Clipper\"\n[markup-converter]: markup-converter.md \"Markup Converter\"\n[graph-visualization]: ../features/graph-visualization.md \"Graph Visualization\"\n[backlinking]: ../features/backlinking.md \"Backlinking\"\n[unlinked-references]: ../../dev/unlinked-references.md \"Unlinked references (stub)\"\n[wikilinks]: ../features/wikilinks.md \"Wikilinks\"\n[commands]: ../features/commands.md \"Foam Commands\"\n[daily-notes]: ../features/daily-notes.md \"Daily Notes\"\n[tags]: ../features/tags.md \"Tags\"\n[note-templates]: ../features/templates.md \"Note Templates\"\n[orphans]: ../tools/orphans.md \"Orphaned Notes\"\n[diagrams-in-markdown]: diagrams-in-markdown.md \"Diagrams in Markdown\"\n[automatically-expand-urls-to-well-titled-links]: automatically-expand-urls-to-well-titled-links.md \"Automatically Expand URLs to Well-Titled Links\"\n[custom-markdown-preview-styles]: ../features/custom-markdown-preview-styles.md \"Custom Markdown Preview Styles\"\n[add-images-to-notes]: add-images-to-notes.md \"Add images to your notes\"\n[shows-image-preview-on-hover]: shows-image-preview-on-hover.md \"Shows Image Preview on Hover\"\n[good-first-task]: ../../dev/good-first-task.md \"Good First Task\"\n[including-notes]: ../features/including-notes.md \"Including notes in a note\"\n[write-your-notes-in-github-gist]: write-your-notes-in-github-gist.md \"Write your notes in GitHub Gist\"\n[automatic-git-syncing]: automatic-git-syncing.md \"Automatically Sync with Git\"\n[publish-to-github-pages]: ../publishing/publish-to-github-pages.md \"GitHub Pages\"\n[publish-to-gitlab-pages]: ../publishing/publish-to-gitlab-pages.md \"GitLab Pages\"\n[publish-to-azure-devops-wiki]: ../publishing/publish-to-azure-devops-wiki.md \"Publish to Azure DevOps Wiki\"\n[publish-to-vercel]: ../publishing/publish-to-vercel.md \"Publish to Vercel\"\n[publish-to-netlify-with-eleventy]: ../publishing/publish-to-netlify-with-eleventy.md \"Publish to Netlify with Eleventy\"\n[generate-gatsby-site]: ../publishing/generate-gatsby-site.md \"Generate a site using Gatsby\"\n[generate-material-for-mkdocs-site]: generate-material-for-mkdocs-site.md \"Generate a site using the Material for MkDocs theme\"\n[publish-to-github]: ../publishing/publish-to-github.md \"Publish to GitHub\"\n[math-support-with-mathjax]: ../publishing/math-support-with-mathjax.md \"Math Support\"\n[math-support-with-katex]: ../publishing/math-support-with-katex.md \"Katex Math Rendering\"\n[export-to-pdf]: export-to-pdf.md \"Export to PDF\"\n[real-time-collaboration]: real-time-collaboration.md \"Real-time Collaboration\"\n[capture-notes-with-drafts-pro]: capture-notes-with-drafts-pro.md \"Capture Notes With Drafts Pro\"\n[capture-notes-with-shortcuts-and-github-actions]: capture-notes-with-shortcuts-and-github-actions.md \"Capture Notes With Shortcuts and GitHub Actions\"\n"
  },
  {
    "path": "docs/user/recipes/search-for-notes.md",
    "content": "# Search for Notes\n\nThis #recipe contains tips on how to leverage VS Code search features.\n\n[[todo]] Add more VS Code search power user tips here\n\nRun `Cmd` + `P` ( `Ctrl` +  `P` on Windows ) and type a name (like 'issues') to find a note associated with that name (like 'known-issues.md' )\n\nRun `Cmd` + `Shift` + `F` ( `Ctrl` + `Shift` + `F` on Windows ) and type a word (like 'links') to find all the notes that contain that term.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[todo]: ../../dev/todo.md \"Todo\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/shows-image-preview-on-hover.md",
    "content": "# Shows Image Preview on Hover\n\nThis #recipe allows you to see a preview of an image on hover.\n\nUse extension: [Image preview](https://marketplace.visualstudio.com/items?itemName=kisstkondoros.vscode-gutter-preview) to shows image preview in the gutter and on hover\n\nIt looks like this\n\n![picture 1](../../assets/images/preview-image-on-hover.png)\n![picture 2](../../assets/images/preview-image-in-glutter.png)\n"
  },
  {
    "path": "docs/user/recipes/take-notes-from-mobile-phone.md",
    "content": "# Take notes on mobile phones\n\nThis #recipe offers solutions to taking Foam notes on the go.\n\nFor the time being we have decided to not build a mobile app, but rely on third parties (see [[build-vs-assemble]]).\n\nThe most promising options are:\n\n### [GitJournal](https://gitjournal.io/)\n\nPros\n\n- Open source\n- Already a usable solution.\n- Provides functionality to edit, create, and browser markdown files.\n- Support journal mode, todo lists, and free writing\n- Syncs to GitHub repo\n- Supports Wikilinks\n- Supports Backlinks\n- Developer is happy to prioritize Foam compatibility\n\nCons\n\n- Doesn't generate link reference lists (but this is ok, since [[workspace-janitor]] as a GitHub action can solve this)\n- Not as sleek as Apple/Google notes, some keyboard state glitching on Android, etc.\n- Lack of control over roadmap. Established product with a paid plan, so may not be open to Foam-supportive changes and additions that don't benefit most users.\n\nVerdict: Good. By far best effort/outcome ratio would be to help improve GitJournal rather than building a [bespoke mobile app](#bespoke-mobile-app-for-foam).\n\n### GitHub Codespaces\n\nPros\n\n- Works out of the box just like the desktop app\n\nCons\n\n- not generally available quite yet\n- [Pricing](https://docs.github.com/en/free-pro-team@latest/github/developing-online-with-codespaces/about-billing-for-codespaces)\n\nFor a quick demo, see <https://www.youtube.com/watch?v=KI5m4Uy8_4I>.\n\nVerdict: Good. Pricing should be reasonable for taking notes on the fly. Harder to assess for people who would constantly use Foam from mobile phone.\n\n## Bespoke mobile app for Foam\n\nGiven we already have a solution, why would we spend time and effort building a bespoke mobile app?\n\n- Taking notes on the go is a key part of a good note taking process, and the process should feel effortless\n- Having a custom app could help us support key user workflows in a more Foam-specific manner\n\nIf such an app was worth building, it would have to have the following features:\n\n- Instant loading and syncing for quick notes\n- Sleek, simple, beautifully designed user experience.\n- Ability to search and navigate forward links and back links (only in paid GitJournal version)\n- Killer feature that makes it the best note taking tool for Foam (?)\n\nGiven the effort vs reward ratio, it's a low priority for core team, but if someone wants to work on this, we can provide support! Talk to us on the #mobile-apps channel on [Foam Discord](https://foambubble.github.io/join-discord/w).\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[build-vs-assemble]: ../../dev/build-vs-assemble.md \"Build vs Assemble\"\n[workspace-janitor]: ../tools/workspace-janitor.md \"Janitor\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/recipes/web-clipper.md",
    "content": "# Web Clipper\n\nThis #recipe allows you to convert any web content into Markdown for storing them in your notes.\n\nThere are a couple of options when it comes to clipping web pages:\n\n- [Web Clipper](https://marketplace.visualstudio.com/items?itemName=jsartelle.web-clipper)\n  - This is a Web Clipper as a VSCode extension, takes a webpage URL and outputs Markdown. Uses [mercury](https://github.com/postlight/mercury-parser)\n\n- [Markdown Clipper](https://github.com/deathau/markdownload)\n  - A Firefox and Google Chrome extension to clip websites and download them into a readable markdown file.\n\n- [Web Clipper](https://clipper.website/)\n  - A Firefox, Chrome and Edge extension to clip websites and save them directly to the GitHub repository into a readable markdown file.\n"
  },
  {
    "path": "docs/user/recipes/write-your-notes-in-github-gist.md",
    "content": "# Write your notes in GitHub Gist\n\nThis #recipe will allow you to persist your notes in a GitHub repository, and automatically sync changes without needing to manually commit/push/pull, then GistPad might be an option worth exploring.\n\n[GistPad](https://aka.ms/gistpad) is a Visual Studio Code extension that allows you to edit your GitHub gists and repos, without needing to clone anything locally.\n\nIt provides support for editing Foam workspaces, complete with `[[link]]` [completion/navigation](https://github.com/vsls-contrib/gistpad#links), [daily pages](https://github.com/vsls-contrib/gistpad#daily-pages), [pasting images](https://github.com/vsls-contrib/gistpad#pasting-images-1) and [backlinks](https://github.com/vsls-contrib/gistpad#backlinks).\n\n<img width=\"700px\" src=\"https://user-images.githubusercontent.com/116461/87234714-96ba9400-c388-11ea-92c3-544d9a3bb633.png\" />\n\n## Getting started\n\nTo start using GistPad for your Foam-based knowledge base, simply perform the following steps:\n\n1. Download the [GistPad extension](https://aka.ms/gistpad) and then re-start Visual Studio Code\n\n1. Run the `GistPad: Sign In` command and then complete the authentication flow using your GitHub account\n\n1. Run the `GistPad: Open Repository` command and select the `Create repo from template...` or `Create private repo from template...` depending on your preference\n\n1. Select the `Foam-style wiki` template, and then specify a name for your Foam workspace (e.g. `my-foam-notes`, `johns-knowledge-base`)\n\nYour new repo will be created in your GitHub account, and the `Foam` welcome page will be automatically opened. If you already have an existing Foam workspace in GitHub, then when you run step #3 above, simply select or specify the name of the GitHub repository instead.\n\n> Note: If you have any and all feedback on how GistPad can be improved to support your Foam workflow, please don't hesitate to [let us know](https://github.com/vsls-contrib/gistpad)! 👍\n\n<img width=\"700px\" src=\"https://user-images.githubusercontent.com/116461/87863222-c1b76180-c90d-11ea-87d9-04bee1c58a0d.png\" />\n\n## Managing your workspace\n\nOnce you've opened/created the Foam repository, it will appear in the `Repositories` view of the `GistPad` tab (the one with the little notebook icon). From this tree view, you can add/edit/delete/rename new pages, upload local files, as well as view the backlinks for each page (they appear as child notes of a page).\n\n<img width=\"250px\" src=\"https://user-images.githubusercontent.com/116461/87234704-83a7c400-c388-11ea-90a8-2a660bef4dc5.png\" />\n\n## Editing your workspace\n\nWhen you create or open a page, you can edit the markdown content as usual, as well as [paste images](https://github.com/vsls-contrib/gistpad#pasting-images-1), and create [`[[links]]` to other pages](https://github.com/vsls-contrib/gistpad#links). When you type `[[`, you'll receive auto-completion for the existing pages in your workspace, and you can also automatically create new pages by simply creating a link to it.\n\nSince you're using the Visual Studio Code markdown editor, you can benefit from all of the rich language services (e.g. syntax highlighting, header collapsing), as well as the extension ecosystem (e.g. [Emojisense](https://marketplace.visualstudio.com/items?itemName=bierner.emojisense)).\n\n## Navigating your workspace\n\nWhen editing a file, you can easily navigate `[[links]]` by hovering over them to see a preview of their contents and/or `cmd+clicking` on them in order to jump to the respective page. Furthermore, when you add a link to a page, a [backlink](https://github.com/vsls-contrib/gistpad#backlinks) is automatically added to it.\n\nYou can view a page's backlinks using either of the following techniques:\n\n1. Expanding the file's node in the `Repositories` tree, since it's child nodes will represent backlinks. This makes it easy to browse your pages and their backlinks in a single hierarchical view.\n\n1. Opening a file, and then viewing it's backlinks list at the bottom of the editor view. This makes it easy to read a page and then see its backlinks in a contextually rich way.\n\n## Daily Pages\n\nIn addition to creating arbitrary pages, you can use GistPad for journaling or capturing [daily notes](https://github.com/vsls-contrib/gistpad#daily-pages). Simply click the calendar icon in the `Repositories` tree, which will open up the page that represents today. If the page doesn't already exist, then it will be created in the workspace before being opened.\n\n<img width=\"700px\" src=\"https://user-images.githubusercontent.com/116461/87234721-b356cc00-c388-11ea-946a-e7f9c92258a6.png\" />\n"
  },
  {
    "path": "docs/user/tools/cli.md",
    "content": "# Command Line Interface\n\nCreate a CLI tool to allow running common Foam commands. These may include:\n\n- `foam init` - create a new foam workspace\n- `foam janitor` - run [[workspace-janitor]] in current workspace\n- `foam migrate <tool>` - migrate from tools like roam exports, obsidian and more\n\nMore commands to be added.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[workspace-janitor]: workspace-janitor.md \"Janitor\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "docs/user/tools/foam-logging-in-vscode.md",
    "content": "# Foam logging in VsCode\n\nThe Foam extension logs details about what its doing in vscode's `Output` tab.\nGenerally this is only useful if you're reporting an issue about Foam.\n\n1. To show the tab, click on `View > Output`.\n2. In the dropdown on the right of the tab, select `Foam`.\n\n![Find the foam log](../../assets/images/foam-log.png)\n\nWhen reporting an issue about Foam, set the log level to `Debug`:\n\n## Change the log level for the session\n\nExecute the command `Foam: Set log level`.\n\n## Change the default logging level\n\n1. Open workspace settings (`cmd+,`, or execute the `Preferences: Open Workspace Settings` command)\n2. Look for the entry `Foam > Logging: Level`\n"
  },
  {
    "path": "docs/user/tools/orphans.md",
    "content": "# Orphaned Notes\n\nFoam helps you to find orphans: notes that have neither forward links nor backlinks.\n\nOrphans can be found in the Orphans panel.\n\nTwo settings allows you to control the behaviour of the Orphans panel:\n\n- `foam.orphans.exclude`: list of glob patterns that will be used to exclude directories. For example, a value of `[\"journal/**/*\"]` would exclude your daily notes.\n- `foam.orphans.groupBy`: sets the default view mode of the Orphans panel: either groups by folder (by default), or lists all orphans. The view can be toggled on the fly from the panel, but it won't overwrite the setting.\n"
  },
  {
    "path": "docs/user/tools/workspace-janitor.md",
    "content": "# Janitor\n\nTo store your personal knowledge graph in markdown files instead of a database, we need some additional tooling to create and maintain relationships with notes.\n\n**Foam Janitor** (inspired by Andy Matuschak's [note-link-janitor](https://github.com/andymatuschak/note-link-janitor)) helps you migrate existing notes to Foam, and maintain your Foam's health over time.\n\nCurrently, Foam's Janitor helps you to:\n\n- Ensure your [[link-reference-definitions]] are up to date\n- Ensure every document has a well-formatted title (required for Markdown Links, Markdown Notes, and Foam Gatsby Template compatibility)\n\nIn the future, Janitor can help you with\n\n- Updating [[materialized-backlinks]]\n- Lint, format and structure notes\n- Rename and move notes around while keeping their references up to date.\n\n## Using Janitor from VS Code (Experimental)\n\nExecute the \"Foam: Run Janitor\" command from the command palette.\n\n![Foam Janitor demo](../../assets/images/foam-janitor-demo.gif)\n\n## Using Janitor from command line (Experimental)\n\n> ⚠️ Improvements to this documentation are welcome!\n\nThe Janitor can be installed from [NPM](https://www.npmjs.com/) and executed as a standalone CLI tool:\n\n```sh\n> npm install -g foam-cli\n> foam janitor path/to/workspace\n```\n\nYou can run the Janitor as a git hook on every commit to ensure your workspace links are up to date. This can be especially helpful if you edit your markdown documents from other apps.\n\nYou can also run the Janitor from a GitHub action to ensure that all changes coming to your workspace are up to date. This can be useful when editing your Foam notes from mobile (i.e. via [GitJournal](https://gitjournal.io)), or your Foam has multiple contributors and you want to ensure that all changes are correctly integrated.\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[link-reference-definitions]: ../features/link-reference-definitions.md \"Link Reference Definitions\"\n[materialized-backlinks]: ../../dev/proposals/materialized-backlinks.md \"Materialized Backlinks (stub)\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "lerna.json",
    "content": "{\n  \"packages\": [\n    \"packages/*\"\n  ],\n  \"npmClient\": \"yarn\",\n  \"useWorkspaces\": true,\n  \"version\": \"0.33.0\"\n}\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"name\": \"foam\",\n  \"version\": \"0.2.0\",\n  \"description\": \"Foam\",\n  \"repository\": \"git@github.com:foambubble/foam.git\",\n  \"license\": \"MIT\",\n  \"private\": \"true\",\n  \"workspaces\": [\n    \"packages/*\",\n    \"packages/foam-vscode/webview-ui/graph\"\n  ],\n  \"scripts\": {\n    \"version-extension\": \"lerna version\",\n    \"package-extension\": \"yarn workspace foam-vscode package-extension\",\n    \"install-extension\": \"yarn workspace foam-vscode install-extension\",\n    \"publish-extension\": \"yarn workspace foam-vscode publish-extension\",\n    \"reset\": \"yarn && yarn clean && yarn build\",\n    \"clean\": \"lerna run clean\",\n    \"build\": \"lerna run build\",\n    \"test\": \"yarn workspace foam-vscode test\",\n    \"test:unit\": \"yarn workspace foam-vscode test:unit\",\n    \"lint\": \"yarn workspace foam-vscode lint\",\n    \"watch\": \"lerna run watch --concurrency 20\",\n    \"prepare\": \"husky\"\n  },\n  \"devDependencies\": {\n    \"all-contributors-cli\": \"^6.16.1\",\n    \"husky\": \"^9.1.7\",\n    \"lerna\": \"^6.4.1\"\n  },\n  \"engines\": {\n    \"node\": \">=18\"\n  },\n  \"prettier\": {\n    \"arrowParens\": \"avoid\",\n    \"printWidth\": 80,\n    \"semi\": true,\n    \"singleQuote\": true,\n    \"trailingComma\": \"es5\"\n  },\n  \"dependencies\": {}\n}\n"
  },
  {
    "path": "packages/foam-vscode/.vscodeignore",
    "content": ".vscode/**\n.vscode-test/**\nout/test/**\nout/**/*.test.*\nout/**/*.spec.*\ntest-data/**\nsrc/**\nwebview-ui/**\njest.config.js\nesbuild.js\n.test-workspace\n.gitignore\n.claude/**\nvsc-extension-quickstart.md\n**/tsconfig.json\n**/.eslintrc.json\n**/*.map\n**/*.ts\nassets/screenshots\nnode_modules\n"
  },
  {
    "path": "packages/foam-vscode/CHANGELOG.md",
    "content": "# Change Log\n\nAll notable changes to the \"foam-vscode\" extension will be documented in this file.\n\nCheck [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.\n\n## 0.33.0\n\nFeatures:\n\n- Add `foam-query` code blocks: embed dynamic, auto-updating note lists, tables, and counts in the Markdown preview (#1597)\n- Support renaming directories — wikilinks are updated when a folder is renamed (#1143)\n\nFixes and Improvements:\n\n- Fix ctrl-click on links to files inside `.foam/` directory creating a new note instead of opening the file (#1589)\n- Improved wikilink embeds\n\n## 0.32.0\n\nFeatures:\n\n- Added support for block-level identifiers, enabling `[[note#^id]]` and `![[note#^id]]` links and embeds (#1593)\n- Directory links now resolve to index files (e.g. `[[bar]]` resolves to `bar/index.md` or `bar/README.md`) (#1591)\n\nFixes and Improvements:\n\n- Don't create a new note when a markdown link targets an existing file (#1379)\n- Delegate markdown link syncing on file move/rename to VS Code built-in (#1069)\n\n## 0.31.0\n\nFixes and Improvements:\n\n- Fixed workspace-relative filepath resolution (#1537, #1590)\n\nInternal:\n\n- Rewrote graph webview from vanilla JavaScript to TypeScript using Lit web components\n- Extracted graph webview as `@foam/graph`, a standalone publishable web component\n\n## 0.30.0\n\nFixes and Improvements:\n\n- Fixed decoding of HTML entities (#1483)\n- Added support for locale in date variables (#1566)\n- Added support for renaming headers (#1588)\n- Fixed removal of Foam frontmatter section from templates on Windows (#1575)\n- Added foam.graph.navigateToPreview setting (#684)\n\n## 0.29.2\n\nFixes and Improvements:\n\n- Fixed FOAM_CURRENT_DIR to use posix style paths (#1573)\n- Fixed highlight of alias in wikilinks (#1451)\n- Fixed excessive blank lines in link reference definitions (#1558, #1561)\n- Added node color mode dropdown in graph with options: 'none', 'Directory' (#1570)\n- Added appearance panel in graph webview (#1555)\n- Added forces panel in graph (#1554)\n- Added selection panel in graph (#1556)\n\n## 0.29.1\n\nFixes and Improvements:\n\n- Load graph in code server (#1400)\n- Don't treat text in single brackets as links/placeholders if missing a ref (#1545, #1546)\n- Added option to show graph on startup (#1542)\n- Added include patterns for Foam notes (#1550, #1422)\n- Added support for emoji variants in tags (#1536, #1549)\n- Added support for wikilink with aliases within tables (#1544, #1552)\n\n## 0.29.0\n\nFixes and Improvements:\n\n- Improved support for wikilink references (#1531, #1116, #1504)\n- Improved tag search to include YAML tags (#1530, #1516)\n- Improved template filepath sanitization (#1533)\n- Added FOAM_DATE_WEEK_YEAR (#1532 - thanks @ChThH)\n- Fixed graph panel moving when revealed - graph now stays in its current location (#1540)\n\n## [0.28.3] - 2025-10-03\n\nFixes and Improvements:\n\n- Fixed sanitation of filepath for templates (#1529 #1526)\n\n## [0.28.2] - 2025-10-01\n\nFixes and Improvements:\n\n- Fixed build for web extension (#1523)\n\n## [0.28.1] - 2025-09-25\n\nFixes and Improvements:\n\n- Removed duplicate links in dataviz graph (#1511 - thanks @Tenormis)\n- Use letter case to further disambiguate note identifiers (#1519, #1303)\n- Sanitize `filepath` before creating note from template (#1520, #1216)\n\n## [0.28.0] - 2025-09-24\n\nFeatures:\n\n- Added workspace symbols for note aliases (#1461)\n- Added tag navigation and peek (#1510)\n- Added support for tag refactoring (#1513)\n- Added support for wikilink images styling (#1514)\n\nFixes and Improvements:\n\n- Added support for image link title attribute (#1514)\n- Exposing FOAM_DATE_DAY_ISO variable (#1512 - thanks @ChThH)\n\n## [0.27.7] - 2025-09-13\n\nFeatures:\n\n- Added `FOAM_DATE_DAY_ISO` template variable for ISO weekday number (1-7, Monday=1)\n\nFixes and Improvements:\n\n- Fixed root-path relative links opening new notes instead of existing files (#1505)\n\n## [0.27.6] - 2025-09-13\n\nFixes and Improvements:\n\n- Fixed URI handling across scheme/authority (fixes #1404)\n\n## [0.27.5] - 2025-09-06\n\nFeatures:\n\n- Added `FOAM_CURRENT_DIR` template variable for explicit current directory context (#1507)\n\n## [0.27.4] - 2025-09-05\n\nFixes and Improvements:\n\n- Fixed double template application when using absolute `filepath` properties (#1499)\n\n## [0.27.3] - 2025-09-05\n\nFixes and Improvements:\n\n- Improved timezone handling for create-note when passing string date\n- Added debugging for daily note issue (#1505, #1502, #1494)\n- Deprecated daily note settings (use daily-note template instead)\n\n## [0.27.2] - 2025-07-25\n\nFixes and Improvements:\n\n- Ensure absolute paths used in create-note command are relative to workspace\n- Improved Windows path handling in URIs\n\n## [0.27.1] - 2025-07-24\n\nFixes and Improvements:\n\n- Fixed handling of daily note template on Windows machines (#1492)\n\n## [0.27.0] - 2025-07-23\n\nFeatures:\n\n- Introduced a unified note creation engine supporting both Markdown and JavaScript templates\n\nInternal:\n\n- Improved testing framework by creating a mocked VS Code environment\n\n## [0.26.12] - 2025-06-18\n\nFixes and Improvements:\n\n- Fix YAML parsing (#1467)\n- Improved regex parsing (#1479 - thanks @s-jacob-powell)\n\n## [0.26.11] - 2025-04-19\n\nFixes and Improvements:\n\n- Support for custom fonts in graph view (#1457 - thanks @Tenormis)\n\n## [0.26.10] - 2025-03-29\n\nFixes and Improvements:\n\n- General improvment of wiki embeds (#1443)\n\n## [0.26.9] - 2025-03-29\n\nFixes and Improvements:\n\n- Defensive get of link object ID in graph (#1438)\n\nInternal:\n\n- Updated `force-graph` library\n\n## [0.26.8] - 2025-03-14\n\nFixes and Improvements:\n\n- Tag hierarchy now visible in graph (#1436)\n- Improved Notes Explorer layout\n\n## [0.26.7] - 2025-03-09\n\nFixes and Improvements:\n\n- Improved parsing of tags (fixes #1434)\n\n## [0.26.6] - 2025-03-08\n\nFixes and Improvements:\n\n- Improved graph based navigation when running in virtual workspace\n- Improved wikilink embeds and fixed cycle detection issue (#1430)\n- Added links in tags to navigate to corresponding tag explorer item (#1432)\n\nInternal:\n\n- Renamed branch from `master` to `main`\n\n## [0.26.5] - 2025-02-21\n\nFixes and Improvements:\n\n- Improved handling of virtual FS URIs (#1426)\n\n## [0.26.4] - 2024-11-12\n\nFixes and Improvements:\n\n- Improved handling of virtual FS URIs (#1409)\n\n## [0.26.3] - 2024-11-12\n\nFixes and Improvements:\n\n- Finetuned use of triemap (#1411 - thanks @pderaaij)\n\n## [0.26.2] - 2024-11-06\n\nFixes and Improvements:\n\n- Performance improvements (#1406 - thanks @pderaaij)\n\n## [0.26.1] - 2024-10-09\n\nFixes and Improvements:\n\n- Fixed issue with Buffer in web extension (#1401 - thanks @pderaaij)\n\n## [0.26.0] - 2024-10-01\n\nFeatures:\n\n- Foam is now a web extension! (#1395 - many thanks @pderaaij)\n\n## [0.25.12] - 2024-07-13\n\nFixes and Improvements:\n\n- Improved YAML support (#1367)\n- Added convesion of wikilinks to markdown links (#1365 - thanks @hereistheusername)\n- Refactored util and settings code\n\n## [0.25.11] - 2024-03-18\n\nFixes and Improvements:\n\n- Actually fixed bug in graph computation (#1345)\n\n## [0.25.10] - 2024-03-18\n\nFixes and Improvements:\n\n- Fixed bug in graph computation (#1345)\n\n## [0.25.9] - 2024-03-17\n\nFixes and Improvements:\n\n- Improved note creation from placeholder (#1344)\n\n## [0.25.8] - 2024-02-21\n\nFixes and Improvements:\n\n- Upgraded dataformat to improve support for daily note naming (#1326 - thanks @rcyeh)\n\n## [0.25.7] - 2024-01-16\n\nFixes and Improvements:\n\n- Modifies url encoding to target only the filename and skip spaces (#1322 - thanks @MABruni)\n- Minor tweak to quick action menu with suggestions for section name\n\n## [0.25.6] - 2023-12-13\n\nFixes and Improvements:\n\n- Fixed wikilink definition encoding (#1311 - thanks @MABruni)\n\n## [0.25.5] - 2023-11-30\n\nFixes and Improvements:\n\n- Using note title in preview (#1309)\n\n## [0.25.4] - 2023-09-19\n\nFixes and Improvements:\n\n- Added support for linking sections within same document (#1289)\n- Fixed note embedding bug (#1286 - thanks @badsketch)\n\n## [0.25.3] - 2023-09-07\n\nFixes and Improvements:\n\n- Fixed incorrect handling of embedding of non-existing notes (#1283 - thanks @badsketch)\n- Introduced Note Embedding Sytanx (#1281 - thanks @badsketch)\n- Attachments are not considered when computing orphan notes (#1242)\n\n## [0.25.2] - 2023-09-02\n\nFixes and Improvements:\n\n- Added content-only embed styles (#1279 - thanks @badsketch)\n- Added expand-all button to tree views (#1276)\n\n## [0.25.1] - 2023-08-23\n\nFixes and Improvements:\n\n- Added support for path parameter in filter (#1250)\n- Added grouping and filtering to tag explorer (#1275)\n- Added new setting to control note embedding (#1273 - thanks @badsketch)\n- Added last week's days to snippets (#1248 - thanks @jimgraham)\n\nInternal:\n\n- Updated jest to v29 (#1271 - thanks @nicholas-l)\n- Improved test cleanup and management (#1274)\n\n## [0.25.0] - 2023-06-30\n\nFeatures:\n\n- Support for multiple extensions and custom default extension (#1235)\n- Added `FOAM_TITLE_SAFE` template variable (#1232)\n\nFixes and Improvements:\n\n- Connections panel tweaks (#1233)\n\n## [0.24.0] - 2023-05-19\n\nFeatures:\n\n- Converted backlinks panel into more general connections panel (#1230)\n\nInternal:\n\n- Improved janitor code (#1228)\n- Refactored code related to tree view panels (#1226)\n- Lint and cleanup (#1224)\n\n## [0.23.0] - 2023-05-06\n\nFeatures:\n\n- Added notes explorer (#1223)\n\nFixes and Improvements:\n\n- Enabled tag completion in front matter (#1191 - thanks @jimgraham)\n- Various improvements to tree views (#1220)\n\n## [0.22.2] - 2023-04-20\n\nFixes and Improvements:\n\n- Support to show placeholders only for open file in panel (#1201, #988)\n- Show note block in panels on hover preview (#1201, #800)\n- Show tag references within tag explorer (#1201)\n- Improved structure of view related commands (#1201)\n- Ignore `.foam` directory\n\n## [0.22.1] - 2023-04-15\n\nFixes and Improvements:\n\n- Allow the `#` char to trigger tag autocompletion (#1192, #1189 - thanks @jimgraham)\n\n## [0.22.0] - 2023-04-15\n\nFixes and Improvements:\n\n- Added support for deep tag hierarchy in Tag Explorer panel (#1134, #1194)\n- Consolidated and improved Backlinks, Placeholders and Orphans panels (#1196)\n- Fixed note resolution when using template without defined path (#1197)\n\n## [0.21.4] - 2023-04-14\n\nFixes and Improvements:\n\n- Fixed issue with generated daily note template due to path escape (#1188, #1190)\n\n## [0.21.3] - 2023-04-12\n\nFixes and Improvements:\n\n- Fixed relative path from workspace root in templates (#1188)\n\n## [0.21.2] - 2023-04-11\n\nFixes and Improvements:\n\n- Fixed embed with relative paths (#1168, #1170)\n- Improved multi-root folder support for daily notes (#1126, #1175)\n- Improved use of tag completion (#1183 - thanks @jimgraham)\n- Fixed relative path use in note creation when using templates (#1170)\n\nInternal:\n\n- Sync user docs with foam-template docs (#1180 - thanks @infogulch)\n\n## [0.21.1] - 2023-02-24\n\nFixes and Improvements:\n\n- Fixed note creation from placeholder (#1172)\n\n## [0.21.0] - 2023-02-16\n\nFeatures:\n\n- Added support for filters for the `foam-vscode.open-resource` command (#1161)\n\n## [0.20.8] - 2023-02-10\n\nInternal:\n\n- Updated most dependencies (#1160)\n\n## [0.20.7] - 2023-01-31\n\nFixes and Improvements:\n\n- Inform the user that directory renaming is not supported (#1143)\n- Fixed extra `web` directory in published extension (#1152 - thanks @piousdeer)\n\n## [0.20.6] - 2023-01-21\n\nFixes and Improvements:\n\n- Updated minimum VS Code version to 1.70.0 (#1140)\n- Fixed preview links with sections (#1135 - thanks @badsketch)\n- Added setting for creating new notes in root or current dir (#1142)\n\n## [0.20.5] - 2023-01-04\n\nFixes and Improvements:\n\n- Fixed entry count in orphan, placeholder, tags-explorer panels (#1131 - thanks @badsketch)\n\n## [0.20.4] - 2023-01-04\n\nFixes and Improvements:\n\n- Added support for emoji tags (#1125 - thanks @badsketch)\n\n## [0.20.3] - 2022-12-19\n\nFixes and Improvements:\n\n- Show number of entries in title for orphan, placeholder, tag treeviews\n\n## [0.20.2] - 2022-10-26\n\nFixes and Improvements:\n\n- Creating new note uses default template when none is provided (#1094)\n\nInternal:\n\n- Changed matcher implementation to remove dependency on micromatch/glob\n- Removed unnecessary dependencies and assets from extension\n\n## [0.20.1] - 2022-10-13\n\nFixes and Improvements:\n\n- Improved support for daily notes in multi root workspace (#1073)\n- Create note from placeholder using template (#1061 - thanks @Dominic-DallOsto)\n- Improved support for globs in multi root workspace (#1083)\n\n## [0.20.0] - 2022-09-30\n\nFeatures:\n\n- Added `foam-vscode.create-note` command, which can be very customized for several use cases (#1076)\n\nFixes and Improvements:\n\n- Removed `+` as a trigger char for date snippets\n- Improved attachment support (#915)\n- Improved error handling when starting Foam without an open workspace (#908)\n- Added support for opening non-text files via wikilink (#915)\n- Dataviz: now clicking is enough to open a link from the graph\n- Dataviz: clicking on images/attachments will open them\n\n## [0.19.5] - 2022-09-01\n\nFixes and Improvements:\n\n- Added `FOAM_DATE_WEEK` variable (#1053 - Thanks @dmurph)\n- Fixed extension inclusion when generating references for attachments\n- Link completion label can be note title as well as path (#1059)\n- Images and attachments are not shown by default in graph view (#1056)\n\n## [0.19.4] - 2022-08-07\n\nFixes and Improvements:\n\n- Fixed note embed in preview (#1052)\n\n## [0.19.3] - 2022-08-04\n\nFixes and Improvements:\n\n- Image embeds fixed in preview (#1036)\n\n## [0.19.2] - 2022-08-04\n\nFixes and Improvements:\n\n- Added support for angle markdown links (#1044)\n- Filter out invalid file name chars when creating note (#1042)\n\nInternal:\n\n- Reorganized docs (#1031, thanks @infogulch)\n- Fixed documentation links (#1046)\n- Preview code refactoring\n\n## [0.19.1] - 2022-07-11\n\nInternal:\n\n- Introduced cache for markdown parser (#1030)\n- Various code refactorings\n\n## [0.19.0] - 2022-07-07\n\nFeatures:\n\n- Support for attachments (PDF) and images (#1027)\n- Support for opening day notes for other days as well (#1026, thanks @alper)\n\n## [0.18.5] - 2022-06-29\n\nFixes and Improvements:\n\n- Support for `alias` YAML property to define note alias (#1014 - thanks @lingyv-li)\n\nInternal:\n\n- Improved extension bundling (#1015 - thanks @lingyv-li)\n- Use `vscode.workspace.fs` instead of `fs` (#1005 - thanks @joshdover)\n\n## [0.18.4] - 2022-06-03\n\nFixes and Improvements:\n\n- move past `]]` when writing wikilinks (#998 - thanks @Lauviah0622)\n- highlight improvements (#890 - thanks @memeplex)\n\n## [0.18.3] - 2022-04-17\n\nFixes and Improvements:\n\n- Better reporting when links fail to resolve\n- Failing link resolution during graph computation no longer fatal\n\n## [0.18.2] - 2022-04-14\n\nFixes and Improvements:\n\n- Fixed parsing error on empty direct links (#980 - thanks @chrisUsick)\n- Improved rendering in preview of wikilinks that have link definitions (#979 - thanks @josephdecock)\n- Restored handling of section-only wikilinks (#981)\n\n## [0.18.1] - 2022-04-13\n\nFixes and Improvements:\n\n- Fixed parsing error for direct links with square brackets in them (#977)\n- Improved markdown direct link resolution (#972)\n- Improved templates support for custom paths (#970)\n\n## [0.18.0] - 2022-04-11\n\nFeatures:\n\n- Link synchronization on file rename\n\nInternal:\n\n- Changed graph computation on workspace change to simplify code\n\n## [0.17.8] - 2022-04-01\n\nFixes and Improvements:\n\n- Do not add ignored files to Foam upon change (#480)\n- Restore full use of editor.action.openLink (#693)\n- Minor performance improvements\n\n## [0.17.7] - 2022-03-29\n\nFixes and Improvements:\n\n- Include links with sections in backlinks (#895)\n- Improved navigation when document editor is already open\n\n## [0.17.6] - 2022-03-03\n\nFixes and Improvements:\n\n- Don't fail on error when scannig workspace (#943 - thanks @develmusa)\n\n## [0.17.5] - 2022-02-22\n\nFixes and Improvements:\n\n- Added FOAM_SLUG template variable (#865 - Thanks @techCarpenter)\n\n## [0.17.4] - 2022-02-13\n\nFixes and Improvements:\n\n- Improvements to Foam variables in templates (#882 - thanks @movermeyer)\n  - Foam variables can now be used just any other VS Code variables, including in combination with placeholders and transformers\n\n## [0.17.3] - 2022-01-14\n\nFixes and Improvements:\n\n- Fixed autocompletion with tags (#885 - thanks @memeplex)\n- Improved \"Open Daily Note\" to be usabled in tasks (#897 - thanks @MCluck90)\n\n## [0.17.2] - 2021-12-22\n\nFixes and Improvements:\n\n- Improved support for wikilinks in titles (#878)\n- Use syntax injection for wikilinks (#876 - thanks @memeplex)\n- Fix when applying text edits in last line\n\nInternal:\n\n- DX: Clean up of testing setup (#881 - thanks @memeplex)\n\n## [0.17.1] - 2021-12-16\n\nFixes and Improvements:\n\n- Decorate markdown files only (#857)\n- Fix template placeholders issue (#859)\n- Improved replacement range for link completion\n\nInternal:\n\n- Major URI/path handling refactoring (#858 - thanks @memeplex)\n\n## [0.17.0] - 2021-12-08\n\nFeatures:\n\n- Added first class support for sections (#856)\n  - Sections can be referred to in wikilinks\n  - Sections can be embedded\n  - Autocompletion for sections\n  - Diagnostic for sections\n  - Embed sections\n\n## [0.16.1] - 2021-11-30\n\nFixes and Improvements:\n\n- Fixed diagnostic bug triggered when file had same suffix (#851)\n\n## [0.16.0] - 2021-11-24\n\nFeatures:\n\n- Added support for unique wikilink identifiers (#841)\n  - This change allows files that have the same name to be uniquely referenced as wikilinks\n  - BREAKING CHANGE: wikilinks to attachments must now include the extension\n- Added diagnostics for ambiguous wikilinks, with quick fixes available (#844)\n- Added support for unique wikilinks in autocompletion (#845)\n\n## [0.15.9] - 2021-11-23\n\nFixes and Improvements:\n\n- Fixed filepath retrieval when creating note from template (#843)\n\n## [0.15.8] - 2021-11-22\n\nFixes and Improvements:\n\n- Re-enable link navigation for wikilinks (#840)\n\n## [0.15.7] - 2021-11-21\n\nFixes and Improvements:\n\n- Fixed template listing (#831)\n- Fixed note creation from template (#834)\n\n## [0.15.6] - 2021-11-18\n\nFixes and Improvements:\n\n- Link Reference Generation is now OFF by default\n- Fixed preview navigation (#830)\n\n## [0.15.5] - 2021-11-15\n\nFixes and Improvements:\n\n- Major improvement in navigation. Use link definitions and link references (#821)\n- Fixed bug showing in hover reference the same more than once when it had multiple links to another (#822)\n\nInternal:\n\n- Foam URI refactoring (#820)\n- Template service refactoring (#825)\n\n## [0.15.4] - 2021-11-09\n\nFixes and Improvements:\n\n- Detached Foam URI from VS Code URI. This should improve several path related issues in Windows. Given how core this change is, the release is just about this refactoring to easily detect possible side effects.\n\n## [0.15.3] - 2021-11-08\n\nFixes and Improvements:\n\n- Avoid delaying decorations on editor switch (#811 - thanks @memeplex)\n- Fix preview issue when embedding a note and using reference definitions (#808 - thanks @pderaaij)\n\n## [0.15.2] - 2021-10-27\n\nFeatures:\n\n- Added `FOAM_DATE_*` template variables (#781)\n\nFixes and Improvements:\n\n- Dataviz: apply note type color to filter item label\n- Dataviz: optimized rendering of graph to reduce load on CPU (#795)\n- Preview: improved tag highlight in preview (#785 - thanks @pderaaij)\n- Better handling of link reference definition (#786 - thanks @pderaaij)\n- Link decorations are now enabled by default (can be turned off in settings)\n\n## [0.15.1] - 2021-10-21\n\nFixes and Improvements:\n\n- Improved filtering controls for graph (#782)\n- Link Hover: Include other connected notes to link target\n\n## [0.15.0] - 2021-10-04\n\nFeatures:\n\n- Preview on hover for wikilinks (#728 - thanks @JonasSprenger)\n- Added tags and controls to graph dataviz (#737 - thanks @dannysemi)\n\nFixes and Improvements:\n\n- Improved tags parsing (#708 - thanks @pderaaij)\n- Fixed support for resources named like JS Object methods (#729 - thanks @JonasSprenger)\n\n## [0.14.2] - 2021-07-24\n\nFeatures:\n\n- Autocompletion for tags (#708 - thanks @pderaaij)\n- Use templates for new note created from wikilink (#712 - thanks @movermeyer)\n\nFixes and Improvements:\n\n- Improved performance of initial file loading (#730 - thanks @pderaaij)\n\n## [0.14.1] - 2021-07-14\n\nFixes and Improvements:\n\n- Fixed NPE that would cause markdown preview to render incorrectly (#718 - thanks @pderaaij)\n\n## [0.14.0] - 2021-07-13\n\nFeatures:\n\n- Create new note from selection (#666 - thanks @pderaaij)\n- Use templates for daily notes (#700 - thanks @movermeyer)\n\nFixes and Improvements:\n\n- Fixed for wikilink aliases in tables (#697 - thanks @pderaaij)\n- Fixed link definition generation in presence of aliased wikilinks (#698 - thanks @pderaaij)\n- Fixed template insertion of selected text (#701 - thanks @movermeyer)\n- Fixed preview navigation (#710 - thanks @pderaaij)\n\n## [0.13.8] - 2021-07-02\n\nFixes and Improvements:\n\n- Improved handling of capitalization in wikilinks (#688 - thanks @pderaaij)\n  - This update will make wikilinks with different capitalization, such as `[[wikilink]]` and `[[WikiLink]]` point to the same file. Please note that means that files that only differ in capitalization across the workspace would now be treated as having the same name\n- Allow dots in wikilinks (#689 - thanks @pderaaij)\n- Fixed a bug in the expansion of date snippets (thanks @syndenham-chorea)\n- Added support for wikilink alias syntax, like `[[wikilink|label]]` (#689 - thanks @pderaaij)\n\n## [0.13.7] - 2021-06-05\n\nFixes and Improvements:\n\n- Fixed #667, incorrect resolution of foam-core library\n\nInternal:\n\n- BREAKING CHANGE: Removed Foam local plugins\n  If you were previously using the alpha feature of Foam local plugins you will soon be able to migrate the functionality to the V1 API\n\n## [0.13.6] - 2021-06-05\n\nFixes and Improvements:\n\n- Fixed #667, incorrect resolution of foam-core library\n\n## [0.13.5] - 2021-06-05\n\nFixes and Improvements:\n\n- Improved support for nested tags (#661 - thanks @pderaaij)\n- Allow YAML metadata in templates (#655 - thanks @movermeyer)\n- Fixed template exclusion globs (#665)\n\n## [0.13.4] - 2021-05-26\n\nFixes and Improvements:\n\n- Added support for nested tags (#643 - thanks @pderaaij)\n- Improved the flow of creating note from template (#645 - thanks @movermeyer)\n- Fixed handling of title property in YAML (#647 - thanks @pderaaij and #546)\n\nInternal:\n\n- Updated various dependencies\n\n## [0.13.3] - 2021-05-09\n\nFixes and Improvements:\n\n- Improved Foam template variables resolution: unknown variables are now ignored (#622 - thanks @movermeyer)\n- Fixed file matching in MarkdownProvider (#617)\n- Fixed cancelling `Foam: Create New Note` and `Foam: Create New Note From Template` behavior (#623 - thanks @movermeyer)\n\n## [0.13.2] - 2021-05-06\n\nFixes and Improvements:\n\n- Fixed wikilink completion bug (#592 - thanks @RobinKing)\n- Added support for stylable tags (#598 - thanks @Barabazs)\n- Added \"Create new note\" command (#601 - thanks @movermeyer)\n- Fixed navigation from placeholder and orphan panel (#600)\n\nInternal:\n\n- Refactored data model representation of resources: `Resource` (#593)\n\n## [0.13.1] - 2021-04-21\n\nFixes and Improvements:\n\n- fixed bug in Windows when running `Open Daily Note` command (#591 - Thanks @RobinKing)\n\n## [0.13.0] - 2021-04-19\n\nFeatures:\n\n- Wikilink completion (#554)\n\nFixes and Improvements:\n\n- fixed link navigation on path with spaces (#542)\n- support for Chinese characters in tags (#567 - thanks @RobinKing)\n- added support for `FOAM_TITLE` in templates (#549 - thanks @movermeyer)\n- added configuration to enable/disable link navigation (#584)\n\n## [0.12.1] - 2021-04-05\n\nFixes and Improvements:\n\n- Link decorations are now optional (#558)\n- Improved UX when creating notes from templates (#550 - thanks @movermeyer)\n\n## [0.12.0] - 2021-03-22\n\nFeatures:\n\n- Launch daily note on startup (#501 - thanks @ingalles)\n- Allow absolute directory in daily notes (#482 - thanks @movermeyer)\n- Navigate wikilinks in Preview even without link definitions (#521)\n- Workspace navigation (links and wikilinks) powered by Foam (#524)\n\nFixes and Improvements:\n\n- Ignore directories that have .md extension (#533 - thanks @movermeyer)\n\n## [0.11.0] - 2021-03-09\n\nFeatures:\n\n- Placeholders Panel: quickly see which placeholders and empty notes are in the workspace (#493 - thanks @joeltjames)\n- Backlinks panel: now a Foam model powered backlinks panel (#514)\n\nFixes and Improvements:\n\n- Dataviz: fixed graph node highlighting (#516, #517)\n\n## [0.10.3] - 2021-03-01\n\nFixes and Improvements:\n\n- Model: fixed wikilink resolution when using link definitions\n- Templates: improved validation during template creation\n\n## [0.10.2] - 2021-02-24\n\nFixes and Improvements:\n\n- Templates: improved the flow of creating a new note from a template\n\n## [0.10.1] - 2021-02-23\n\nFixes and Improvements:\n\n- Model: fixed consolidation of model after change events\n- Dataviz: improved consolidation of graph\n\n## [0.10.0] - 2021-02-18\n\nFeatures:\n\n- Notes preview in panels (#468 - thanks @leonhfr)\n- Added more style options to graph setting (lineColor, lineWidth, particleWidth (#479 - thanks @nitwit-se)\n\nInternal:\n\n- Refactored data model representation of notes graph: `FoamWorkspace` (#467)\n\n## [0.9.1] - 2021-01-28\n\nFixes and Improvements:\n\n- Panel: Updating orphan panel when adding and removing notes (#464 - thanks @leonhfr)\n\n## [0.9.0] - 2021-01-27\n\nFeatures:\n\n- Panel: Added orphan panel (#457 - thanks @leonhfr)\n\n## [0.8.0] - 2021-01-15\n\nFeatures:\n\n- Model: Now direct links are included in the Foam model (#433)\n- Commaands: Added `Open random note` command (#440 - thanks @MCluck90)\n- Dataviz: Added graph style override from VsCode theme (#438 - thanks @jmg-duarte)\n- Dataviz: Added graph style customization based on note type (#449)\n\nFixes and Improvements:\n\n- Various improvements and fixes in documentation (thanks @anglinb, @themaxdavitt, @elswork)\n\n## [0.7.7] - 2020-12-31\n\nFixes and Improvements:\n\n- Fixed word-based-suggestions (#415 #417 - thanks @bpugh!)\n- Date snippets use standard wikilink syntax (#416 - thanks @MCluck90!)\n\n## [0.7.6] - 2020-12-20\n\nFixes and Improvements:\n\n- Fixed \"Janitor\" command issue in Windows (#410)\n\n## [0.7.5] - 2020-12-17\n\nFixes and Improvements:\n\n- Fixed \"Open Daily Note\" command issue in Windows (#407)\n\n## [0.7.4] - 2020-12-16\n\nFixes and Improvements:\n\n- Fixed a bug that was causing Foam to not work correctly in Windows (#391)\n\n## [0.7.3] - 2020-12-13\n\nFixes and Improvements:\n\n- Foam model: fix to link references on node update/deletion (#393 - thanks @AndrewNatoli)\n- Dataviz: fix hover/selection (#401)\n- Dataviz: improved logging\n- Dataviz: style tweaks for better readability\n\n## [0.7.2] - 2020-11-27\n\nFixes and Improvements:\n\n- Dataviz: Sync note deletion\n- Foam model: Fix to wikilink format (#386 - thanks @SanketDG)\n\n## [0.7.1] - 2020-11-27\n\nFeatures:\n\n- Foam logging can now be inspected in VsCode Output panel (#377)\n\nFixes and Improvements:\n\n- Foam model: Fixed bug in tags parsing (#382)\n- Dataviz: Graph canvas now resizes with window (#383, #375)\n- Dataviz: Limit label length for placeholder nodes (#381)\n\n## [0.7.0] - 2020-11-25\n\nFeatures:\n\n- Foam stays in sync with changes in notes\n- Dataviz: Added multiple selection in graph (shift+click on node)\n\nFixes and Improvements:\n\n- Dataviz: Graph uses VSCode theme colors\n- Reporting: Errors occurring during foam bootstrap are now reported for easier debugging\n\n## [0.6.0] - 2020-11-19\n\nFeatures:\n\n- Added command to create notes from templates (#115 - Thanks @ingalless)\n\nFixes and Improvements:\n\n- Foam model: Fixed bug that prevented wikilinks from being slugified (#323 - thanks @SanketDG)\n- Editor: Improvements in defaults for ignored files setting (thanks @jmg-duarte)\n- Dataviz: Centering of the graph on note displayed in active editor (#319)\n- Dataviz: Improved graph styling\n- Dataviz: Added setting to cap the length of labels in the graph (thanks @jmg-duarte)\n- Misc: Fixed problem with packaging icon in extension (#350 - thanks @litanlitudan)\n\n## [0.5.0] - 2020-11-09\n\nFeatures:\n\n- Added tags panel (#311)\n\nFixes and Improvements:\n\n- Date snippets now support configurable completion actions (#307 - thanks @ingalless)\n- Graph now show note titles when zooming in (#310)\n- New `foam.files.ignore` setting to exclude globs from being processed by Foam (#304 - thanks @jmg-duarte)\n- Errors in YAML parsing no longer causes foam to crash (#320)\n- Fixed error in CLI command janitor & migrate (#312 - thanks @hikerpig)\n\n## [0.4.0] - 2020-10-28\n\nFeatures:\n\n- Added `Foam: Show Graph` command\n- Added date snippets (/+1d, ...) to create wikilinks to dates in daily note format\n- Added `Foam: Copy to Clipboard without brackets` command\n\nImprovements:\n\n- Added new option to not generate wikilink definitions `foam.edit.linkReferenceDefinitions`: `off`\n\nBug Fixes:\n\n- Daily note could be created before the daily note directory (#232)\n- Fix issue with janitor crashing when file is only frontmatter (#222)\n- Fix link references spacing when there is no trailing newline (#236)\n\nNew experimental features:\n\n- Introduced [foam local plugins](https://foambubble.github.io/foam/foam-local-plugins)\n\n## [0.3.1] - 2020-07-26\n\nFixes and improvements:\n\n- Fix [Daily Notes](https://foambubble.github.io/foam/daily-notes) command on Windows.\n\n## [0.3.0] - 2020-07-25\n\nFeatures:\n\n- [Daily Notes](https://foambubble.github.io/foam/daily-notes)\n- [Janitor](https://foambubble.github.io/foam/workspace-janitor) for updating headings and link references across your workspace\n\nFixes and improvements:\n\n- [Configuration setting for generating link reference definitions with file extension](https://foambubble.github.io/foam/link-reference-definitions#configuration) to support standard markdown tools, such as GitHub web UI\n- [Improvements to how new notes are indexed](https://github.com/foambubble/foam/pull/156)\n\n## [0.2.0] - 2020-07-12\n\nImprovements:\n\n- Order link references alphabetically to cause smaller diffs\n- Remove link references when links are removed\n- Documentation improvements\n\nUnderneath, everything has changed:\n\n- Published from [Foam monorepo](https://github.com/foambubble/foam)\n- Rewrote markdown parsing to use unifiedjs AST\n- Rewrote workspace index to user graphlib graph data structures\n\nThese changes will enable to make more robust and ambitious releases more frequently 🎉\n\n## [0.1.7] - 2020-07-04\n\n- Support paths to files in subdirectories\n\n## [0.1.6] - 2020-07-02\n\n- Add support for VS Code 1.45.1\n\n## [0.1.5] - 2020-06-29\n\n- Fix multiple issues related to excess/disappearing newlines ([#3](https://github.com/foambubble/foam-vscode/issues/3), [#5](https://github.com/foambubble/foam-vscode/issues/5), [#10](https://github.com/foambubble/foam-vscode/issues/10))\n\n## [0.1.4] - 2020-06-25\n\n- Fix flaky reference block replacement logic that would occasionally leave\n  trailing fragments in the end of the document ([#3](https://github.com/foambubble/foam-vscode/issues/3))\n\n## 0.1.3 - 2020-06-25\n\n- Include Getting Started instructions\n\n## [0.1.2] - 2020-06-24\n\n- Update extension name.\n\n## [0.1.1] - 2020-06-24\n\n- Fix markdown link format (`link.md` to just `link`).\n\n## [0.1.0] - 2020-06-24\n\n- Initial release\n"
  },
  {
    "path": "packages/foam-vscode/LICENSE",
    "content": "The MIT Licence (MIT)\n\nCopyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>\n\nPermission is hereby granted, free of charge, to any person obtaining a copy of\nthis software and associated documentation files (the \"Software\"), to deal in\nthe Software without restriction, including without limitation the rights to\nuse, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of\nthe Software, and to permit persons to whom the Software is furnished to do so,\nsubject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS\nFOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR\nCOPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER\nIN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN\nCONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.\n\nWhere noted, some code uses the following license:\n\nMIT License\n\nCopyright (c) 2015 - present Microsoft Corporation\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n"
  },
  {
    "path": "packages/foam-vscode/README.md",
    "content": "<div align=\"center\">\n<img src=\"assets/icon/FOAM_ICON_256.png\" width=\"100\"/>\n\n# Foam for VSCode\n\n[![Version](https://img.shields.io/visual-studio-marketplace/v/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Downloads](https://img.shields.io/visual-studio-marketplace/d/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs&cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)\n\n</div>\n\n[Foam](https://foambubble.github.io/foam) is a note-taking tool that lives within VS Code, which means you can pair it with your favorite extensions for a great editing experience.\n\nFoam is open source, and allows you to create a local first, markdown based, personal knowledge base. You can also use it to publish your notes.\n\nFoam is also meant to be extensible, so you can integrate with its internals to customize your knowledge base.\n\n## Features\n\n### Graph Visualization\n\nSee how your notes are connected via a [graph](https://foambubble.github.io/foam/user/features/graph-visualization) with the `Foam: Show Graph` command.\n\n![Graph Visualization](./assets/screenshots/feature-show-graph.gif)\n\n### Foam Queries\n\nEmbed dynamic, auto-updating lists, tables, and counts of notes directly in the Markdown preview using `foam-query` and `foam-query-js` code blocks.\nSee the [Foam Queries documentation](https://foambubble.github.io/foam/user/features/foam-queries) for the full reference.\n\n![Foam queries](./assets/screenshots/foam-query.gif)\n\n### Block IDs\n\nLink or embed specific paragraphs, list items, headings, and blockquotes within a note using `[[note#^blockid]]` syntax.\nAdd a `^id` marker to any block element, then reference it from anywhere in your knowledge base.\n\n![Block IDs](./assets/screenshots/block-ids.gif)\n\n### Note embed\n\nEmbed the content from other notes. Embed entire notes, sections or even just blocks.\n\n![Note Embed](./assets/screenshots/feature-note-embed.gif)\n\n### Link Autocompletion\n\nFoam helps you create the connections between your notes, and your placeholders as well.\n\n![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)\n\n### Sync links on file rename\n\nFoam updates the links to renamed files, so your notes stay consistent.\n\n![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)\n\n### Unique identifiers across directories\n\nFoam supports files with the same name in multiple directories.\nIt will use the minimum identifier required, and even report and help you fix existing ambiguous wikilinks.\n\n![Unique identifier autocompletion](./assets/screenshots/feature-unique-wikilink-completion.gif)\n\n![Wikilink diagnostic](./assets/screenshots/feature-wikilink-diagnostics.gif)\n\n### Link Preview and Navigation\n\n![Link Preview and Navigation](./assets/screenshots/feature-navigation.gif)\n\n### Go to definition, Peek References\n\nSee where a note is being referenced in your knowledge base.\n\n![Go to Definition, Peek References](./assets/screenshots/feature-definition-references.gif)\n\n### Navigation in Preview\n\nNavigate your rendered notes in the VS Code preview panel.\n\n![Navigation in Preview](./assets/screenshots/feature-preview-navigation.gif)\n\n### Support for sections\n\nFoam supports autocompletion, navigation, embedding and diagnostics for note sections.\nJust use the standard wiki syntax of `[[resource#Section Title]]`.\n\n### Link Alias\n\nFoam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.\n\n### Templates\n\nUse [custom templates](https://foambubble.github.io/foam/user/features/templates) to have avoid repetitve work on your notes.\n\n![Templates](./assets/screenshots/feature-templates.gif)\n\n### Backlinks Panel\n\nQuickly check which notes are referencing the currently active note.\nSee for each occurrence the context in which it lives, as well as a preview of the note.\n\n![Backlinks Panel](./assets/screenshots/feature-backlinks-panel.gif)\n\n### Tag Explorer Panel\n\nTag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/user/features/tags).\nFoam also supports hierarchical tags.\n\n![Tag Explorer Panel](./assets/screenshots/feature-tags-panel.gif)\n\n### Orphans and Placeholder Panels\n\nOrphans are note that have no inbound nor outbound links.\nPlaceholders are dangling links, or notes without content.\nKeep them under control, and your knowledge base in better state, by using this panel.\n\n![Orphans and Placeholder Panels](./assets/screenshots/feature-placeholder-orphan-panel.gif)\n\n### Syntax highlight\n\nFoam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.\n\n![Syntax Highlight](./assets/screenshots/feature-syntax-highlight.png)\n\n### Daily note\n\nCreate a journal with [daily notes](https://foambubble.github.io/foam/user/features/daily-notes).\n\n![Daily Note](./assets/screenshots/feature-daily-note.gif)\n\n### Generate references for your wikilinks\n\nCreate markdown [references](https://foambubble.github.io/foam/user/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.\nWith references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.\n\n![Generate references](./assets/screenshots/feature-definitions-generation.gif)\n\n### Commands\n\n- Explore your knowledge base with the `Foam: Open Random Note` command\n- Access your daily note with the `Foam: Open Daily Note` command\n- Create a new note with the `Foam: Create New Note` command\n  - This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/user/features/templates) and the `Foam: Create New Note from Template` command\n- See your workspace as a connected graph with the `Foam: Show Graph` command\n- And many [more](https://foambubble.github.io/foam/user/features/commands)\n\n## Recipes\n\nPeople use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/user/recipes/recipes) page for inspiration!\n\n## Getting started\n\nYou really, _really_, **really** should read [Foam documentation](https://foambubble.github.io/foam), but if you can't be bothered, this is how to get started:\n\n1. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.\n2. Clone the repository and open it in VS Code.\n3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).\n\nThis will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.\n\n## Known Issues\n\nSee the [issues](https://github.com/foambubble/foam/issues/) on our GitHub repo ;)\n\n## Release Notes\n\nSee the [CHANGELOG](CHANGELOG.md).\n"
  },
  {
    "path": "packages/foam-vscode/__mocks__/vscode.ts",
    "content": "/*\nNote: this is needed in order to test certain parts\nof functionality of `foam-vscode`\n\nFollowing the advice from this article:\nhttps://www.richardkotze.com/coding/unit-test-mock-vs-code-extension-api-jest\n\ncombined with advice from this GitHub issue comment:\nhttps://github.com/microsoft/vscode-test/issues/37#issuecomment-584744386\n*/\n\nconst vscode = {\n    // Add values and methods as needed for tests\n};\n\nmodule.exports = vscode;"
  },
  {
    "path": "packages/foam-vscode/esbuild.js",
    "content": "// also see https://code.visualstudio.com/api/working-with-extensions/bundling-extension\nconst assert = require('assert');\nconst esbuild = require('esbuild');\nconst polyfillPlugin = require('esbuild-plugin-polyfill-node');\n\n// pass the platform to esbuild as an argument\n\nfunction getPlatform() {\n  const args = process.argv.slice(2);\n  const pArg = args.find(arg => arg.startsWith('--platform='));\n  if (pArg) {\n    return pArg.split('=')[1];\n  }\n  throw new Error('No platform specified. Pass --platform <web|node|webview>.');\n}\n\nconst platform = getPlatform();\nassert(\n  ['web', 'node'].includes(platform),\n  'Platform must be \"web\" or \"node\".'\n);\n\nconst production = process.argv.includes('--production');\nconst watch = process.argv.includes('--watch');\n\nconst config = {\n  web: {\n    platform: 'browser',\n    format: 'cjs',\n    outfile: `out/bundles/extension-web.js`,\n    define: {\n      global: 'globalThis',\n    },\n    plugins: [\n      polyfillPlugin.polyfillNode({\n        // Options (optional)\n      }),\n      {\n        name: 'path-browserify',\n        setup(build) {\n          build.onResolve({ filter: /^path$/ }, args => {\n            return { path: require.resolve('path-browserify') };\n          });\n        },\n      },\n      {\n        name: 'wikilink-embed',\n        setup(build) {\n          build.onResolve({ filter: /wikilink-embed/ }, args => {\n            return {\n              path: require.resolve(\n                args.resolveDir + '/wikilink-embed-web-extension.ts'\n              ),\n            };\n          });\n        },\n      },\n      {\n        name: 'parse-entities-no-dom',\n        setup(build) {\n          // parse-entities maps ./decode-entity -> ./decode-entity.browser.js via\n          // its package.json `browser` field when platform=browser. That version\n          // uses document.createElement which is unavailable in VS Code Web\n          // Extension host (Web Worker). Intercept the import before esbuild\n          // applies the browser field remap and redirect to the Node.js version\n          // that uses a pure lookup table instead.\n          // Addresses #1566\n          build.onResolve({ filter: /\\/decode-entity$/ }, args => {\n            if (args.resolveDir.includes('parse-entities')) {\n              return {\n                path: require.resolve('parse-entities/decode-entity.js'),\n              };\n            }\n          });\n        },\n      },\n    ],\n  },\n  node: {\n    platform: 'node',\n    format: 'cjs',\n    outfile: `out/bundles/extension-node.js`,\n    plugins: [],\n  },\n};\n\nasync function buildExtension() {\n  const ctx = await esbuild.context({\n    ...config[platform],\n    entryPoints: ['src/extension.ts'],\n    bundle: true,\n    minify: production,\n    sourcemap: !production,\n    sourcesContent: false,\n    external: ['vscode'],\n    logLevel: 'silent',\n    plugins: [\n      ...config[platform].plugins,\n      /* add to the end of plugins array */\n      esbuildProblemMatcherPlugin,\n    ],\n  });\n  if (watch) {\n    await ctx.watch();\n  } else {\n    await ctx.rebuild();\n    await ctx.dispose();\n  }\n}\n\nasync function main() {\n  await buildExtension();\n}\n\n/**\n * @type {import('esbuild').Plugin}\n */\nconst esbuildProblemMatcherPlugin = {\n  name: 'esbuild-problem-matcher',\n\n  setup(build) {\n    build.onStart(() => {\n      console.log('[watch] build started');\n    });\n    build.onEnd(result => {\n      result.errors.forEach(({ text, location }) => {\n        console.error(`✘ [ERROR] ${text}`);\n        console.error(\n          `    ${location.file}:${location.line}:${location.column}:`\n        );\n      });\n      console.log('[watch] build finished');\n    });\n  },\n};\n\nmain().catch(e => {\n  console.error(e);\n  process.exit(1);\n});\n"
  },
  {
    "path": "packages/foam-vscode/jest.config.js",
    "content": "// For a detailed explanation regarding each configuration property, visit:\n// https://jestjs.io/docs/en/configuration.html\n\nmodule.exports = {\n  // All imported modules in your tests should be mocked automatically\n  // automock: false,\n\n  // Stop running tests after `n` failures\n  // bail: 0,\n\n  // The directory where Jest should store its cached dependency information\n  // cacheDirectory: \"/private/var/folders/p6/5y5l8tbs1d32pq9b596lk48h0000gn/T/jest_dx\",\n\n  // Automatically clear mock calls and instances between every test\n  clearMocks: true,\n\n  // Indicates whether the coverage information should be collected while executing the test\n  // collectCoverage: false,\n\n  // An array of glob patterns indicating a set of files for which coverage information should be collected\n  // collectCoverageFrom: undefined,\n\n  // The directory where Jest should output its coverage files\n  // coverageDirectory: undefined,\n\n  // An array of regexp pattern strings used to skip coverage collection\n  // coveragePathIgnorePatterns: [\n  //   \"/node_modules/\"\n  // ],\n\n  // Indicates which provider should be used to instrument code for coverage\n  // coverageProvider: \"babel\",\n\n  // A list of reporter names that Jest uses when writing coverage reports\n  // coverageReporters: [\n  //   \"json\",\n  //   \"text\",\n  //   \"lcov\",\n  //   \"clover\"\n  // ],\n\n  // An object that configures minimum threshold enforcement for coverage results\n  // coverageThreshold: undefined,\n\n  // A path to a custom dependency extractor\n  // dependencyExtractor: undefined,\n\n  // Make calling deprecated APIs throw helpful error messages\n  // errorOnDeprecated: false,\n\n  // Force coverage collection from ignored files using an array of glob patterns\n  // forceCoverageMatch: [],\n\n  // A path to a module which exports an async function that is triggered once before all test suites\n  // globalSetup: undefined,\n\n  // A path to a module which exports an async function that is triggered once after all test suites\n  // globalTeardown: undefined,\n\n  // A set of global variables that need to be available in all test environments\n  // globals: {},\n\n  // The maximum amount of workers used to run your tests. Can be specified as % or a number. E.g. maxWorkers: 10% will use 10% of your CPU amount + 1 as the maximum worker number. maxWorkers: 2 will use a maximum of 2 workers.\n  // maxWorkers: \"50%\",\n\n  // An array of directory names to be searched recursively up from the requiring module's location\n  // moduleDirectories: [\n  //   \"node_modules\"\n  // ],\n\n  // An array of file extensions your modules use\n  // moduleFileExtensions: [\n  //   \"js\",\n  //   \"json\",\n  //   \"jsx\",\n  //   \"ts\",\n  //   \"tsx\",\n  //   \"node\"\n  // ],\n\n  // A map from regular expressions to module names or to arrays of module names that allow to stub out resources with a single module\n  // moduleNameMapper: {},\n\n  // An array of regexp pattern strings, matched against all module paths before considered 'visible' to the module loader\n  modulePathIgnorePatterns: ['.vscode-test'],\n\n  // Activates notifications for test results\n  // notify: false,\n\n  // An enum that specifies notification mode. Requires { notify: true }\n  // notifyMode: \"failure-change\",\n\n  // A preset that is used as a base for Jest's configuration\n  preset: 'ts-jest',\n\n  // Run tests from one or more projects\n  // projects: undefined,\n\n  // Use this configuration option to add custom reporters to Jest\n  // reporters: undefined,\n\n  // Automatically reset mock state between every test\n  // resetMocks: false,\n\n  // Reset the module registry before running each individual test\n  // resetModules: false,\n\n  // A path to a custom resolver\n  // resolver: undefined,\n\n  // Automatically restore mock state between every test\n  // restoreMocks: false,\n\n  // The root directory that Jest should scan for tests and modules within\n  // rootDir: undefined,\n\n  // A list of paths to directories that Jest should use to search for files in\n  // roots: [\n  //   \"<rootDir>\"\n  // ],\n\n  // Allows you to use a custom runner instead of Jest's default test runner\n  // runner: \"jest-runner\",\n\n  // The paths to modules that run some code to configure or set up the testing environment before each test\n  setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],\n\n  // A list of paths to modules that run some code to configure or set up the testing framework before each test\n  setupFilesAfterEnv: ['jest-extended'],\n\n  // A list of paths to snapshot serializer modules Jest should use for snapshot testing\n  // snapshotSerializers: [],\n\n  // The test environment that will be used for testing\n  testEnvironment: 'node',\n\n  // Options that will be passed to the testEnvironment\n  // testEnvironmentOptions: {},\n\n  // Adds a location field to test results\n  // testLocationInResults: false,\n\n  // The glob patterns Jest uses to detect test files\n  // testMatch: [\n  //   \"**/__tests__/**/*.[jt]s?(x)\",\n  //   \"**/?(*.)+(spec|test).[tj]s?(x)\"\n  // ],\n\n  // An array of regexp pattern strings that are matched against all test paths, matched tests are skipped\n  // testPathIgnorePatterns: [\n  //   \"/node_modules/\"\n  // ],\n\n  // The regexp pattern or array of patterns that Jest uses to detect test files\n  // This is overridden in every runCLI invocation but it's here as the default\n  // for vscode-jest. Both .test.ts and .spec.ts files use the vscode-mock.\n  testRegex: ['\\\\.(test|spec)\\\\.ts$'],\n\n  // This option allows the use of a custom results processor\n  // testResultsProcessor: undefined,\n\n  // This option allows use of a custom test runner\n  // testRunner: \"jasmine2\",\n\n  // This option sets the URL for the jsdom environment. It is reflected in properties such as location.href\n  // testURL: \"http://localhost\",\n\n  // Setting this value to \"fake\" allows the use of fake timers for functions such as \"setTimeout\"\n  // timers: \"real\",\n\n  // A map from regular expressions to paths to transformers\n  // transform: undefined,\n\n  // An array of regexp pattern strings that are matched against all source file paths, matched files will skip transformation\n  // transformIgnorePatterns: [\n  //   \"/node_modules/\"\n  // ],\n\n  // An array of regexp pattern strings that are matched against all modules before the module loader will automatically return a mock for them\n  // unmockedModulePathPatterns: undefined,\n\n  // Indicates whether each individual test should be reported during the run\n  // verbose: undefined,\n\n  // An array of regexp patterns that are matched against all source file paths before re-running tests in watch mode\n  // watchPathIgnorePatterns: [],\n\n  // Whether to use watchman for file crawling\n  // watchman: true,\n};\n"
  },
  {
    "path": "packages/foam-vscode/package.json",
    "content": "{\n  \"name\": \"foam-vscode\",\n  \"displayName\": \"Foam\",\n  \"description\": \"VS Code + Markdown + Wikilinks for your note taking and knowledge base\",\n  \"private\": true,\n  \"repository\": {\n    \"url\": \"https://github.com/foambubble/foam\",\n    \"type\": \"git\"\n  },\n  \"homepage\": \"https://github.com/foambubble/foam\",\n  \"version\": \"0.33.0\",\n  \"license\": \"MIT\",\n  \"publisher\": \"foam\",\n  \"engines\": {\n    \"vscode\": \"^1.109.0\"\n  },\n  \"icon\": \"assets/icon/FOAM_ICON_256.png\",\n  \"categories\": [\n    \"Other\"\n  ],\n  \"main\": \"./out/bundles/extension-node.js\",\n  \"browser\": \"./out/bundles/extension-web.js\",\n  \"capabilities\": {\n    \"untrustedWorkspaces\": {\n      \"supported\": \"limited\",\n      \"description\": \"No expressions are allowed in filters.\"\n    }\n  },\n  \"contributes\": {\n    \"markdown.markdownItPlugins\": true,\n    \"markdown.previewStyles\": [\n      \"./static/preview/style.css\"\n    ],\n    \"markdown.previewScripts\": [\n      \"./static/preview/block-anchor-scroll.js\"\n    ],\n    \"grammars\": [\n      {\n        \"path\": \"./syntaxes/injection.json\",\n        \"scopeName\": \"foam.wikilink.injection\",\n        \"injectTo\": [\n          \"text.html.markdown\"\n        ]\n      }\n    ],\n    \"colors\": [\n      {\n        \"id\": \"foam.placeholder\",\n        \"description\": \"Color of foam placeholders.\",\n        \"defaults\": {\n          \"dark\": \"editorWarning.foreground\",\n          \"light\": \"editorWarning.foreground\",\n          \"highContrast\": \"editorWarning.foreground\"\n        }\n      }\n    ],\n    \"views\": {\n      \"explorer\": [\n        {\n          \"id\": \"foam-vscode.connections\",\n          \"name\": \"Connections\",\n          \"icon\": \"$(references)\",\n          \"contextualTitle\": \"Foam\"\n        },\n        {\n          \"id\": \"foam-vscode.tags-explorer\",\n          \"name\": \"Tag Explorer\",\n          \"icon\": \"$(tag)\",\n          \"contextualTitle\": \"Foam\"\n        },\n        {\n          \"id\": \"foam-vscode.notes-explorer\",\n          \"name\": \"Notes\",\n          \"icon\": \"$(notebook)\",\n          \"contextualTitle\": \"Foam\"\n        },\n        {\n          \"id\": \"foam-vscode.orphans\",\n          \"name\": \"Orphans\",\n          \"icon\": \"$(debug-gripper)\",\n          \"contextualTitle\": \"Foam\"\n        },\n        {\n          \"id\": \"foam-vscode.placeholders\",\n          \"name\": \"Placeholders\",\n          \"icon\": \"$(debug-disconnect)\",\n          \"contextualTitle\": \"Foam\"\n        },\n        {\n          \"when\": \"config.foam.experimental.ai\",\n          \"id\": \"foam-vscode.related-notes\",\n          \"name\": \"Related Notes (AI)\",\n          \"icon\": \"$(sparkle)\",\n          \"contextualTitle\": \"Foam\"\n        }\n      ]\n    },\n    \"viewsWelcome\": [\n      {\n        \"view\": \"foam-vscode.tags-explorer\",\n        \"contents\": \"No tags found. Notes that contain tags will show up here. You may add tags to a note with a hashtag (#tag) or by adding a tag list to the front matter (tags: tag1, tag2).\"\n      },\n      {\n        \"view\": \"foam-vscode.connections\",\n        \"contents\": \"Nothing found for the selected resource and the current filter.\"\n      },\n      {\n        \"view\": \"foam-vscode.orphans\",\n        \"contents\": \"No orphans found. Notes that have no backlinks nor links will show up here.\"\n      },\n      {\n        \"view\": \"foam-vscode.placeholders\",\n        \"contents\": \"No placeholders found for selected resource or workspace.\"\n      },\n      {\n        \"view\": \"foam-vscode.related-notes\",\n        \"contents\": \"Open a note to see related notes.\",\n        \"when\": \"config.foam.experimental.ai && foam.relatedNotes.state == 'no-note'\"\n      },\n      {\n        \"view\": \"foam-vscode.related-notes\",\n        \"contents\": \"Notes haven't been analyzed yet.\\n[Analyze Notes](command:foam-vscode.build-embeddings)\\nAnalyze your notes to discover similar content.\",\n        \"when\": \"config.foam.experimental.ai && foam.relatedNotes.state == 'no-embedding'\"\n      },\n      {\n        \"view\": \"foam-vscode.related-notes\",\n        \"contents\": \"No similar notes found for the current note.\",\n        \"when\": \"config.foam.experimental.ai && foam.relatedNotes.state == 'ready'\"\n      }\n    ],\n    \"menus\": {\n      \"view/item/context\": [\n        {\n          \"command\": \"foam-vscode.search-tag\",\n          \"when\": \"view == foam-vscode.tags-explorer && viewItem == tag\",\n          \"group\": \"inline\",\n          \"icon\": \"$(search)\"\n        },\n        {\n          \"command\": \"foam-vscode.rename-tag\",\n          \"when\": \"view == foam-vscode.tags-explorer && viewItem == tag\",\n          \"group\": \"inline\",\n          \"icon\": \"$(edit)\"\n        }\n      ],\n      \"editor/context\": [\n        {\n          \"command\": \"foam-vscode.rename-tag\",\n          \"when\": \"editorTextFocus && resourceExtname == '.md'\",\n          \"group\": \"foam\",\n          \"title\": \"Rename Tag\"\n        }\n      ],\n      \"view/title\": [\n        {\n          \"command\": \"foam-vscode.views.connections.show:backlinks\",\n          \"when\": \"view == foam-vscode.connections && foam-vscode.views.connections.show == 'all links'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.connections.show:forward-links\",\n          \"when\": \"view == foam-vscode.connections && foam-vscode.views.connections.show == 'backlinks'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.connections.show:all-links\",\n          \"when\": \"view == foam-vscode.connections && foam-vscode.views.connections.show == 'forward links'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.orphans.group-by:folder\",\n          \"when\": \"view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'off'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.orphans.group-by:off\",\n          \"when\": \"view == foam-vscode.orphans && foam-vscode.views.orphans.group-by == 'folder'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.show:for-current-file\",\n          \"when\": \"view == foam-vscode.tags-explorer && foam-vscode.views.tags-explorer.show == 'all'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.show:all\",\n          \"when\": \"view == foam-vscode.tags-explorer && foam-vscode.views.tags-explorer.show == 'for-current-file'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.group-by:folder\",\n          \"when\": \"view == foam-vscode.tags-explorer && foam-vscode.views.tags-explorer.group-by == 'off'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.group-by:off\",\n          \"when\": \"view == foam-vscode.tags-explorer && foam-vscode.views.tags-explorer.group-by == 'folder'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.expand-all\",\n          \"when\": \"view == foam-vscode.tags-explorer\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.show:for-current-file\",\n          \"when\": \"view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'all'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.show:all\",\n          \"when\": \"view == foam-vscode.placeholders && foam-vscode.views.placeholders.show == 'for-current-file'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.group-by:folder\",\n          \"when\": \"view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'off'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.group-by:off\",\n          \"when\": \"view == foam-vscode.placeholders && foam-vscode.views.placeholders.group-by == 'folder'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.expand-all\",\n          \"when\": \"view == foam-vscode.placeholders\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.show:notes\",\n          \"when\": \"view == foam-vscode.notes-explorer && foam-vscode.views.notes-explorer.show == 'all'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.show:all\",\n          \"when\": \"view == foam-vscode.notes-explorer && foam-vscode.views.notes-explorer.show == 'notes-only'\",\n          \"group\": \"navigation\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.expand-all\",\n          \"when\": \"view == foam-vscode.notes-explorer\",\n          \"group\": \"navigation\"\n        }\n      ],\n      \"commandPalette\": [\n        {\n          \"command\": \"foam-vscode.create-note-from-default-template\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.update-graph\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.connections.show:all-links\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.connections.show:backlinks\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.connections.show:forward-links\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.orphans.group-by:folder\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.orphans.group-by:off\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.show:for-current-file\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.show:all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.group-by:folder\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.group-by:off\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.tags-explorer.expand-all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.show:for-current-file\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.show:all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.group-by:folder\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.group-by:off\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.placeholders.expand-all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.show:all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.show:notes\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.views.notes-explorer.expand-all\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.open-resource\",\n          \"when\": \"false\"\n        },\n        {\n          \"command\": \"foam-vscode.completion-move-cursor\",\n          \"when\": \"false\"\n        }\n      ]\n    },\n    \"commands\": [\n      {\n        \"command\": \"foam-vscode.create-note\",\n        \"title\": \"Foam: Create New Note\"\n      },\n      {\n        \"command\": \"foam-vscode.clear-cache\",\n        \"title\": \"Foam: Clear Cache\"\n      },\n      {\n        \"command\": \"foam-vscode.update-graph\",\n        \"title\": \"Foam: Update Graph\"\n      },\n      {\n        \"command\": \"foam-vscode.set-log-level\",\n        \"title\": \"Foam: Set Log Level\"\n      },\n      {\n        \"command\": \"foam-vscode.show-graph\",\n        \"title\": \"Foam: Show Graph\"\n      },\n      {\n        \"command\": \"foam-vscode.update-wikilink-definitions\",\n        \"title\": \"Foam: Update Wikilink Definitions\"\n      },\n      {\n        \"command\": \"foam-vscode.open-daily-note\",\n        \"title\": \"Foam: Open Today's Note\"\n      },\n      {\n        \"command\": \"foam-vscode.open-daily-note-for-date\",\n        \"title\": \"Foam: Open Daily Note\"\n      },\n      {\n        \"command\": \"foam-vscode.open-random-note\",\n        \"title\": \"Foam: Open Random Note\"\n      },\n      {\n        \"command\": \"foam-vscode.janitor\",\n        \"title\": \"Foam: Run Janitor (Experimental)\"\n      },\n      {\n        \"command\": \"foam-vscode.copy-without-brackets\",\n        \"title\": \"Foam: Copy To Clipboard Without Brackets\"\n      },\n      {\n        \"command\": \"foam-vscode.create-note-from-template\",\n        \"title\": \"Foam: Create New Note From Template\"\n      },\n      {\n        \"command\": \"foam-vscode.create-note-from-default-template\",\n        \"title\": \"Foam: Create New Note\"\n      },\n      {\n        \"command\": \"foam-vscode.open-resource\",\n        \"title\": \"Foam: Open Resource\"\n      },\n      {\n        \"command\": \"foam-vscode.convert-wikilink-to-mdlink\",\n        \"title\": \"Foam: Convert Wikilink to Markdown Link\"\n      },\n      {\n        \"command\": \"foam-vscode.convert-mdlink-to-wikilink\",\n        \"title\": \"Foam: Convert Markdown Link to Wikilink\"\n      },\n      {\n        \"command\": \"foam-vscode.search-tag\",\n        \"title\": \"Foam: Search Tag\",\n        \"icon\": \"$(search)\"\n      },\n      {\n        \"command\": \"foam-vscode.rename-tag\",\n        \"title\": \"Foam: Rename Tag\",\n        \"icon\": \"$(edit)\"\n      },\n      {\n        \"command\": \"foam-vscode.show-similar-notes\",\n        \"title\": \"Foam: Show Similar Notes\",\n        \"when\": \"config.foam.experimental.ai\"\n      },\n      {\n        \"command\": \"foam-vscode.build-embeddings\",\n        \"title\": \"Foam: Build Embeddings Index\",\n        \"when\": \"config.foam.experimental.ai\"\n      },\n      {\n        \"command\": \"foam-vscode.views.orphans.group-by:folder\",\n        \"title\": \"Group By Folder\",\n        \"icon\": \"$(list-tree)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.connections.show:backlinks\",\n        \"title\": \"Show Backlinks\",\n        \"icon\": \"$(arrow-left)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.connections.show:forward-links\",\n        \"title\": \"Show Links\",\n        \"icon\": \"$(arrow-right)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.connections.show:all-links\",\n        \"title\": \"Show All\",\n        \"icon\": \"$(arrow-swap)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.orphans.group-by:off\",\n        \"title\": \"Flat list\",\n        \"icon\": \"$(list-flat)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.show:for-current-file\",\n        \"title\": \"Show tags in current file\",\n        \"icon\": \"$(file)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.show:all\",\n        \"title\": \"Show tags in workspace\",\n        \"icon\": \"$(files)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.group-by:folder\",\n        \"title\": \"Group By Folder\",\n        \"icon\": \"$(list-tree)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.group-by:off\",\n        \"title\": \"Flat list\",\n        \"icon\": \"$(list-flat)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.expand-all\",\n        \"title\": \"Expand all\",\n        \"icon\": \"$(expand-all)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.tags-explorer.focus\",\n        \"title\": \"Focus on tag\",\n        \"icon\": \"$(symbol-number)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.placeholders.show:for-current-file\",\n        \"title\": \"Show placeholders in current file\",\n        \"icon\": \"$(file)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.placeholders.show:all\",\n        \"title\": \"Show placeholders in workspace\",\n        \"icon\": \"$(files)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.placeholders.group-by:folder\",\n        \"title\": \"Group By Folder\",\n        \"icon\": \"$(list-tree)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.placeholders.group-by:off\",\n        \"title\": \"Flat list\",\n        \"icon\": \"$(list-flat)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.placeholders.expand-all\",\n        \"title\": \"Expand all\",\n        \"icon\": \"$(expand-all)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.notes-explorer.show:all\",\n        \"title\": \"Show all resources\",\n        \"icon\": \"$(files)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.notes-explorer.expand-all\",\n        \"title\": \"Expand all\",\n        \"icon\": \"$(expand-all)\"\n      },\n      {\n        \"command\": \"foam-vscode.views.notes-explorer.show:notes\",\n        \"title\": \"Show only notes\",\n        \"icon\": \"$(file)\"\n      },\n      {\n        \"command\": \"foam-vscode.create-new-template\",\n        \"title\": \"Foam: Create New Template\"\n      },\n      {\n        \"command\": \"foam-vscode.completion-move-cursor\",\n        \"title\": \"Foam: Move cursor after completion\"\n      }\n    ],\n    \"configuration\": {\n      \"title\": \"Foam\",\n      \"properties\": {\n        \"foam.supportedLanguages\": {\n          \"type\": \"array\",\n          \"default\": [\n            \"markdown\"\n          ],\n          \"description\": \"List of languages to treat as Markdown-like documents.\"\n        },\n        \"foam.completion.label\": {\n          \"type\": \"string\",\n          \"default\": \"path\",\n          \"description\": \"Describes what note property to use as a label for completion items\",\n          \"enum\": [\n            \"path\",\n            \"title\",\n            \"identifier\"\n          ],\n          \"enumDescriptions\": [\n            \"Use the path of the note\",\n            \"Use the title of the note\",\n            \"Use the identifier of the note\"\n          ]\n        },\n        \"foam.completion.useAlias\": {\n          \"type\": \"string\",\n          \"default\": \"never\",\n          \"description\": \"Specifies in which cases to use an alias when creating a wikilink\",\n          \"enum\": [\n            \"never\",\n            \"whenPathDiffersFromTitle\"\n          ],\n          \"enumDescriptions\": [\n            \"Never use aliases in completion items\",\n            \"Use alias if resource path is different from title\"\n          ]\n        },\n        \"foam.completion.linkFormat\": {\n          \"type\": \"string\",\n          \"default\": \"wikilink\",\n          \"description\": \"Controls the format of completed links\",\n          \"enum\": [\n            \"wikilink\",\n            \"link\"\n          ],\n          \"enumDescriptions\": [\n            \"Complete as wikilinks (e.g., [[note-name]])\",\n            \"Complete as markdown links (e.g., [Note Name](note-name.md))\"\n          ]\n        },\n        \"foam.files.ignore\": {\n          \"type\": [\n            \"array\"\n          ],\n          \"default\": [\n            \"**/.vscode/**/*\",\n            \"**/_layouts/**/*\",\n            \"**/_site/**/*\",\n            \"**/node_modules/**/*\"\n          ],\n          \"description\": \"Specifies the list of globs that will be ignored by Foam (e.g. they will not be considered when creating the graph). To ignore all the content of a given folder, use `<folderName>/**/*`\",\n          \"deprecationMessage\": \"Use 'foam.files.exclude' instead. This setting will be removed in a future version.\"\n        },\n        \"foam.files.exclude\": {\n          \"type\": [\n            \"array\"\n          ],\n          \"default\": [],\n          \"description\": \"Specifies the list of globs that will be excluded by Foam (e.g. they will not be considered when creating the graph). To exclude all the content of a given folder, use `<folderName>/**/*`. This setting is combined with 'foam.files.ignore' (deprecated) and 'files.exclude'.\"\n        },\n        \"foam.files.include\": {\n          \"type\": [\n            \"array\"\n          ],\n          \"default\": [\n            \"**/*\"\n          ],\n          \"description\": \"Specifies the list of glob patterns for files to include in Foam. Files must match at least one include pattern and not match any exclude patterns. Use this to limit Foam to specific directories (e.g., [\\\"notes/**\\\"]) or file types (e.g., [\\\"**/*.md\\\"]). Defaults to all files.\"\n        },\n        \"foam.files.attachmentExtensions\": {\n          \"type\": \"string\",\n          \"default\": \"pdf mp3 webm wav m4a mp4 avi mov rtf txt doc docx pages xls xlsx numbers ppt pptm pptx\",\n          \"description\": \"Space separated list of file extensions that will be considered attachments\"\n        },\n        \"foam.files.notesExtensions\": {\n          \"type\": \"string\",\n          \"default\": \"\",\n          \"description\": \"Space separated list of extra file extensions that will be considered text notes (e.g. 'mdx txt markdown')\"\n        },\n        \"foam.files.defaultNoteExtension\": {\n          \"type\": \"string\",\n          \"default\": \"md\",\n          \"description\": \"The default extension for new notes\"\n        },\n        \"foam.templates.folder\": {\n          \"type\": \"string\",\n          \"default\": \".foam/templates\",\n          \"description\": \"The folder where Foam looks for note templates, relative to the workspace root.\"\n        },\n        \"foam.files.newNotePath\": {\n          \"type\": \"string\",\n          \"default\": \"root\",\n          \"description\": \"Specifies where to create a new note. It is overruled by the template or command arguments\",\n          \"enum\": [\n            \"root\",\n            \"currentDir\"\n          ],\n          \"enumDescriptions\": [\n            \"Use the root of the workspace\",\n            \"Use the directory of the file in the current editor\"\n          ]\n        },\n        \"foam.logging.level\": {\n          \"type\": \"string\",\n          \"default\": \"info\",\n          \"enum\": [\n            \"off\",\n            \"debug\",\n            \"info\",\n            \"warn\",\n            \"error\"\n          ]\n        },\n        \"foam.edit.linkReferenceDefinitions\": {\n          \"type\": \"string\",\n          \"default\": \"off\",\n          \"enum\": [\n            \"withExtensions\",\n            \"withoutExtensions\",\n            \"off\"\n          ],\n          \"enumDescriptions\": [\n            \"Include extension in wikilinks paths\",\n            \"Remove extension in wikilink paths\",\n            \"Disable wikilink definitions generation\"\n          ]\n        },\n        \"foam.links.directory.mode\": {\n          \"type\": \"string\",\n          \"default\": \"resolve\",\n          \"description\": \"Controls how links to a directory name (e.g. [[bar]] or [label](bar)) are resolved. 'resolve' looks for an index file inside the directory (index.<ext> then README.<ext>). 'disabled' restores strict file-only resolution.\",\n          \"enum\": [\n            \"resolve\",\n            \"disabled\"\n          ],\n          \"enumDescriptions\": [\n            \"Resolve directory links to an index file (index.<ext> or README.<ext>) inside the directory\",\n            \"Disable directory link resolution; only exact file matches are used\"\n          ]\n        },\n        \"foam.links.sync.enable\": {\n          \"description\": \"Automatically update wikilinks when a note is renamed or moved. For standard markdown links, enable VS Code's built-in 'markdown.updateLinksOnFileMove.enabled' setting.\",\n          \"type\": \"boolean\",\n          \"default\": true\n        },\n        \"foam.links.hover.enable\": {\n          \"description\": \"Enable displaying note content on hover links\",\n          \"type\": \"boolean\",\n          \"default\": true\n        },\n        \"foam.dateLocale\": {\n          \"type\": \"string\",\n          \"default\": \"default\",\n          \"description\": \"The locale used for date variables such as FOAM_DATE_DAY_NAME and FOAM_DATE_MONTH_NAME. Use 'default' to use the system locale, or a BCP 47 language tag (e.g. 'en-US', 'ja-JP').\"\n        },\n        \"foam.openDailyNote.onStartup\": {\n          \"type\": \"boolean\",\n          \"default\": false\n        },\n        \"foam.openDailyNote.fileExtension\": {\n          \"deprecationMessage\": \"This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md\",\n          \"type\": \"string\",\n          \"default\": \"md\"\n        },\n        \"foam.openDailyNote.filenameFormat\": {\n          \"deprecationMessage\": \"This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md\",\n          \"type\": \"string\",\n          \"default\": \"isoDate\",\n          \"markdownDescription\": \"Specifies how the daily note filename is formatted. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats\"\n        },\n        \"foam.openDailyNote.titleFormat\": {\n          \"deprecationMessage\": \"This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ],\n          \"default\": null,\n          \"markdownDescription\": \"Specifies how the daily note title is formatted. Will default to the filename format if set to null. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats\"\n        },\n        \"foam.openDailyNote.directory\": {\n          \"deprecationMessage\": \"This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md\",\n          \"type\": [\n            \"string\",\n            \"null\"\n          ],\n          \"default\": null,\n          \"description\": \"The directory into which daily notes should be created. Defaults to the workspace root.\"\n        },\n        \"foam.orphans.exclude\": {\n          \"type\": [\n            \"array\"\n          ],\n          \"default\": [],\n          \"markdownDescription\": \"Specifies the list of glob patterns that will be excluded from the orphans report. To ignore the all the content of a given folder, use `**<folderName>/**/*`\"\n        },\n        \"foam.placeholders.exclude\": {\n          \"type\": [\n            \"array\"\n          ],\n          \"default\": [],\n          \"markdownDescription\": \"Specifies the list of glob patterns that will be excluded from the placeholders report. To ignore the all the content of a given folder, use `**<folderName>/**/*`\"\n        },\n        \"foam.dateSnippets.afterCompletion\": {\n          \"type\": \"string\",\n          \"default\": \"createNote\",\n          \"enum\": [\n            \"noop\",\n            \"createNote\",\n            \"navigateToNote\"\n          ],\n          \"enumDescriptions\": [\n            \"Nothing happens after selecting the completion item\",\n            \"The note is created following your daily note settings if it does not exist, but no navigation takes place\",\n            \"Navigates to the note, creating it following your daily note settings if it does not exist\"\n          ],\n          \"description\": \"Whether or not to navigate to the target daily note when a daily note snippet is selected.\"\n        },\n        \"foam.preview.embedNoteType\": {\n          \"when\": \"config.foam.experimental.ai\",\n          \"type\": \"string\",\n          \"default\": \"full-card\",\n          \"enum\": [\n            \"full-inline\",\n            \"full-card\",\n            \"content-inline\",\n            \"content-card\"\n          ],\n          \"enumDescriptions\": [\n            \"Include the section with title and style inline\",\n            \"Include the section with title and style it within a container\",\n            \"Include the section without title and style inline\",\n            \"Include the section without title and style it within a container\"\n          ]\n        },\n        \"foam.graph.titleMaxLength\": {\n          \"type\": \"number\",\n          \"default\": 24,\n          \"description\": \"The maximum title length before being abbreviated. Set to 0 or less to disable.\"\n        },\n        \"foam.graph.style\": {\n          \"type\": \"object\",\n          \"description\": \"Custom graph styling settings. An example is present in the documentation.\",\n          \"default\": {}\n        },\n        \"foam.graph.onStartup\": {\n          \"type\": \"boolean\",\n          \"default\": false,\n          \"description\": \"Whether to open the graph on startup.\"\n        },\n        \"foam.graph.navigateToPreview\": {\n          \"type\": \"boolean\",\n          \"default\": false,\n          \"description\": \"When clicking a node in the graph, open the markdown preview instead of the source editor.\"\n        }\n      }\n    },\n    \"keybindings\": [\n      {\n        \"command\": \"foam-vscode.open-daily-note\",\n        \"key\": \"alt+d\"\n      },\n      {\n        \"command\": \"foam-vscode.open-daily-note-for-date\",\n        \"key\": \"alt+h\"\n      }\n    ]\n  },\n  \"scripts\": {\n    \"build:node\": \"node esbuild.js --platform=node\",\n    \"build:web\": \"node esbuild.js --platform=web\",\n    \"build:webview\": \"yarn workspace @foam/graph build\",\n    \"build\": \"yarn build:webview && yarn build:node && yarn build:web\",\n    \"vscode:prepublish\": \"yarn clean && yarn build:node --production && yarn build:web --production\",\n    \"compile\": \"tsc -p ./\",\n    \"test-reset-workspace\": \"rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep\",\n    \"test-setup\": \"yarn compile && yarn build && yarn test-reset-workspace\",\n    \"test\": \"yarn test-setup && node ./out/test/run-tests.js\",\n    \"test:unit\": \"yarn test-setup && node ./out/test/run-tests.js --unit\",\n    \"test:unit-without-specs\": \"yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs\",\n    \"test:e2e\": \"yarn test-setup && node ./out/test/run-tests.js --e2e\",\n    \"lint\": \"dts lint src\",\n    \"clean\": \"rimraf out\",\n    \"watch\": \"nodemon --watch 'src/**/*.ts' --exec 'yarn build' --ext ts\",\n    \"vscode:start-debugging\": \"yarn clean && yarn watch\",\n    \"package-extension\": \"npx @vscode/vsce@3.6.0 package --yarn\",\n    \"install-extension\": \"code --install-extension ./foam-vscode-$npm_package_version.vsix\",\n    \"open-in-browser\": \"vscode-test-web --quality=stable --browser=chromium --extensionDevelopmentPath=. \",\n    \"publish-extension-openvsx\": \"npx ovsx publish foam-vscode-$npm_package_version.vsix -p $OPENVSX_TOKEN\",\n    \"publish-extension-vscode\": \"npx vsce publish --packagePath foam-vscode-$npm_package_version.vsix\",\n    \"publish-extension\": \"yarn publish-extension-vscode && yarn publish-extension-openvsx\"\n  },\n  \"devDependencies\": {\n    \"@types/dateformat\": \"^3.0.1\",\n    \"@types/jest\": \"^29.5.3\",\n    \"@types/lodash\": \"^4.14.157\",\n    \"@types/markdown-it\": \"^12.0.1\",\n    \"@types/micromatch\": \"^4.0.1\",\n    \"@types/node\": \"^18.0.0\",\n    \"@types/picomatch\": \"^2.2.1\",\n    \"@types/remove-markdown\": \"^0.1.1\",\n    \"@types/vscode\": \"^1.109.0\",\n    \"@typescript-eslint/eslint-plugin\": \"^5.51.0\",\n    \"@typescript-eslint/parser\": \"^5.51.0\",\n    \"@vscode/test-web\": \"^0.0.80\",\n    \"dts-cli\": \"^1.6.3\",\n    \"esbuild\": \"^0.25.0\",\n    \"esbuild-plugin-polyfill-node\": \"^0.3.0\",\n    \"eslint\": \"^8.33.0\",\n    \"eslint-import-resolver-typescript\": \"^3.5.3\",\n    \"eslint-plugin-import\": \"^2.27.5\",\n    \"eslint-plugin-jest\": \"^27.2.1\",\n    \"jest\": \"^29.6.2\",\n    \"jest-extended\": \"^3.2.3\",\n    \"markdown-it\": \"^12.0.4\",\n    \"micromatch\": \"^4.0.2\",\n    \"nodemon\": \"^3.1.7\",\n    \"rimraf\": \"^3.0.2\",\n    \"ts-jest\": \"^29.1.1\",\n    \"tslib\": \"^2.0.0\",\n    \"typescript\": \"^4.9.5\",\n    \"vscode-test\": \"^1.3.0\",\n    \"wait-for-expect\": \"^3.0.2\"\n  },\n  \"dependencies\": {\n    \"dateformat\": \"4.5.1\",\n    \"dayjs\": \"^1.11.13\",\n    \"detect-newline\": \"^3.1.0\",\n    \"github-slugger\": \"^1.4.0\",\n    \"gray-matter\": \"^4.0.2\",\n    \"js-sha1\": \"^0.7.0\",\n    \"lodash\": \"^4.17.21\",\n    \"lru-cache\": \"^7.14.1\",\n    \"markdown-it-regex\": \"^0.2.0\",\n    \"mnemonist\": \"^0.39.8\",\n    \"path-browserify\": \"^1.0.1\",\n    \"remark-frontmatter\": \"^2.0.0\",\n    \"remark-parse\": \"^8.0.2\",\n    \"remark-wiki-link\": \"^0.0.4\",\n    \"title-case\": \"^3.0.2\",\n    \"unified\": \"^9.0.0\",\n    \"unist-util-visit\": \"^2.0.2\",\n    \"yaml\": \"^2.2.2\"\n  },\n  \"__metadata\": {\n    \"id\": \"b85c6625-454b-4b61-8a22-c42f3d0f2e1e\",\n    \"publisherDisplayName\": \"Foam\",\n    \"publisherId\": \"34339645-24f0-4619-9917-12157fd92446\",\n    \"isPreReleaseVersion\": false\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/model/embedding-cache.ts",
    "content": "import { URI } from '../../core/model/uri';\nimport { ICache } from '../../core/utils/cache';\n\ntype Checksum = string;\n\n/**\n * Cache entry for embeddings\n */\nexport interface EmbeddingCacheEntry {\n  checksum: Checksum;\n  embedding: number[];\n}\n\n/**\n * Cache for embeddings, keyed by URI\n */\nexport type EmbeddingCache = ICache<URI, EmbeddingCacheEntry>;\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/model/embeddings.test.ts",
    "content": "import { FoamEmbeddings } from './embeddings';\nimport {\n  EmbeddingProvider,\n  EmbeddingProviderInfo,\n} from '../services/embedding-provider';\nimport {\n  createTestWorkspace,\n  InMemoryDataStore,\n  waitForExpect,\n} from '../../test/test-utils';\nimport { URI } from '../../core/model/uri';\n\n// Helper to create a simple mock provider\nclass MockProvider implements EmbeddingProvider {\n  async embed(text: string): Promise<number[]> {\n    const vector = new Array(384).fill(0);\n    vector[0] = text.length / 100; // Deterministic based on text length\n    return vector;\n  }\n  async isAvailable(): Promise<boolean> {\n    return true;\n  }\n  getProviderInfo(): EmbeddingProviderInfo {\n    return {\n      name: 'Test Provider',\n      type: 'local',\n      model: { name: 'test-model', dimensions: 384 },\n    };\n  }\n}\n\nconst ROOT = [URI.parse('/', 'file')];\n\ndescribe('FoamEmbeddings', () => {\n  describe('cosineSimilarity', () => {\n    it('should return 1 for identical vectors', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const vector = [1, 2, 3, 4, 5];\n      const similarity = embeddings.cosineSimilarity(vector, vector);\n      expect(similarity).toBeCloseTo(1.0, 5);\n      workspace.dispose();\n    });\n\n    it('should return 0 for orthogonal vectors', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const vec1 = [1, 0, 0];\n      const vec2 = [0, 1, 0];\n      const similarity = embeddings.cosineSimilarity(vec1, vec2);\n      expect(similarity).toBeCloseTo(0.0, 5);\n      workspace.dispose();\n    });\n\n    it('should return -1 for opposite vectors', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const vec1 = [1, 0, 0];\n      const vec2 = [-1, 0, 0];\n      const similarity = embeddings.cosineSimilarity(vec1, vec2);\n      expect(similarity).toBeCloseTo(-1.0, 5);\n      workspace.dispose();\n    });\n\n    it('should return 0 for zero vectors', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const vec1 = [0, 0, 0];\n      const vec2 = [1, 2, 3];\n      const similarity = embeddings.cosineSimilarity(vec1, vec2);\n      expect(similarity).toBe(0);\n      workspace.dispose();\n    });\n\n    it('should throw error for vectors of different lengths', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const vec1 = [1, 2, 3];\n      const vec2 = [1, 2];\n      expect(() => embeddings.cosineSimilarity(vec1, vec2)).toThrow();\n      workspace.dispose();\n    });\n  });\n\n  describe('updateResource', () => {\n    it('should create embedding for a resource', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      const noteUri = URI.parse('/path/to/note.md', 'file');\n      datastore.set(noteUri, '# Test Note\\n\\nThis is test content');\n      await workspace.fetchAndSet(noteUri);\n\n      await embeddings.updateResource(noteUri);\n\n      const embedding = embeddings.getEmbedding(noteUri);\n      expect(embedding).not.toBeNull();\n      expect(embedding?.length).toBe(384);\n\n      workspace.dispose();\n    });\n\n    it('should remove embedding when resource is deleted', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      const noteUri = URI.parse('/path/to/note.md', 'file');\n      datastore.set(noteUri, '# Test Note\\n\\nContent');\n      await workspace.fetchAndSet(noteUri);\n\n      await embeddings.updateResource(noteUri);\n      expect(embeddings.getEmbedding(noteUri)).not.toBeNull();\n\n      workspace.delete(noteUri);\n      await embeddings.updateResource(noteUri);\n\n      expect(embeddings.getEmbedding(noteUri)).toBeNull();\n\n      workspace.dispose();\n    });\n\n    it('should create different embeddings for different content', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      const note1Uri = URI.parse('/note1.md', 'file');\n      const note2Uri = URI.parse('/note2.md', 'file');\n\n      // Same title, different content\n      datastore.set(note1Uri, '# Same Title\\n\\nShort content');\n      datastore.set(\n        note2Uri,\n        '# Same Title\\n\\nThis is much longer content that should produce a different embedding vector'\n      );\n\n      await workspace.fetchAndSet(note1Uri);\n      await workspace.fetchAndSet(note2Uri);\n\n      await embeddings.updateResource(note1Uri);\n      await embeddings.updateResource(note2Uri);\n\n      const embedding1 = embeddings.getEmbedding(note1Uri);\n      const embedding2 = embeddings.getEmbedding(note2Uri);\n\n      expect(embedding1).not.toBeNull();\n      expect(embedding2).not.toBeNull();\n\n      // Embeddings should be different because content is different\n      // Our mock provider uses text.length for the first vector component\n      expect(embedding1![0]).not.toBe(embedding2![0]);\n\n      workspace.dispose();\n    });\n  });\n\n  describe('hasEmbeddings', () => {\n    it('should return false when no embeddings exist', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      expect(embeddings.hasEmbeddings()).toBe(false);\n      workspace.dispose();\n    });\n\n    it('should return true when embeddings exist', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      const noteUri = URI.parse('/path/to/note.md', 'file');\n      datastore.set(noteUri, '# Note\\n\\nContent');\n      await workspace.fetchAndSet(noteUri);\n\n      await embeddings.updateResource(noteUri);\n\n      expect(embeddings.hasEmbeddings()).toBe(true);\n\n      workspace.dispose();\n    });\n  });\n\n  describe('getSimilar', () => {\n    it('should return empty array when no embedding exists for target', () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n      const uri = URI.parse('/path/to/note.md', 'file');\n\n      const similar = embeddings.getSimilar(uri, 5);\n\n      expect(similar).toEqual([]);\n      workspace.dispose();\n    });\n\n    it('should return similar notes sorted by similarity', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      // Create notes with different content lengths\n      const note1Uri = URI.parse('/note1.md', 'file');\n      const note2Uri = URI.parse('/note2.md', 'file');\n      const note3Uri = URI.parse('/note3.md', 'file');\n\n      datastore.set(note1Uri, '# Note 1\\n\\nShort');\n      datastore.set(note2Uri, '# Note 2\\n\\nMedium length text');\n      datastore.set(note3Uri, '# Note 3\\n\\nVery long text content here');\n\n      await workspace.fetchAndSet(note1Uri);\n      await workspace.fetchAndSet(note2Uri);\n      await workspace.fetchAndSet(note3Uri);\n\n      await embeddings.updateResource(note1Uri);\n      await embeddings.updateResource(note2Uri);\n      await embeddings.updateResource(note3Uri);\n\n      // Get similar to note2\n      const similar = embeddings.getSimilar(note2Uri, 10);\n\n      expect(similar.length).toBe(2); // Excludes self\n      expect(similar[0].uri.path).toBeTruthy();\n      expect(similar[0].similarity).toBeGreaterThanOrEqual(\n        similar[1].similarity\n      );\n\n      workspace.dispose();\n    });\n\n    it('should respect topK parameter', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      // Create multiple notes\n      for (let i = 0; i < 10; i++) {\n        const noteUri = URI.parse(`/note${i}.md`, 'file');\n        datastore.set(noteUri, `# Note ${i}\\n\\nContent ${i}`);\n        await workspace.fetchAndSet(noteUri);\n        await embeddings.updateResource(noteUri);\n      }\n\n      const target = URI.parse('/note0.md', 'file');\n      const similar = embeddings.getSimilar(target, 3);\n\n      expect(similar.length).toBe(3);\n\n      workspace.dispose();\n    });\n\n    it('should not include self in similar results', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = new FoamEmbeddings(workspace, new MockProvider());\n\n      const noteUri = URI.parse('/note.md', 'file');\n      datastore.set(noteUri, '# Note\\n\\nContent');\n      await workspace.fetchAndSet(noteUri);\n      await embeddings.updateResource(noteUri);\n\n      const similar = embeddings.getSimilar(noteUri, 10);\n\n      expect(similar.find(s => s.uri.path === noteUri.path)).toBeUndefined();\n\n      workspace.dispose();\n    });\n  });\n\n  describe('fromWorkspace with monitoring', () => {\n    it('should automatically update when resource is added', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const embeddings = FoamEmbeddings.fromWorkspace(\n        workspace,\n        new MockProvider(),\n        true\n      );\n\n      const noteUri = URI.parse('/new-note.md', 'file');\n      datastore.set(noteUri, '# New Note\\n\\nContent');\n      await workspace.fetchAndSet(noteUri);\n\n      // Give it a moment to process\n      await new Promise(resolve => setTimeout(resolve, 100));\n\n      const embedding = embeddings.getEmbedding(noteUri);\n      expect(embedding).not.toBeNull();\n\n      embeddings.dispose();\n      workspace.dispose();\n    });\n\n    it('should automatically update when resource is modified', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const noteUri = URI.parse('/note.md', 'file');\n\n      datastore.set(noteUri, '# Note\\n\\nOriginal content');\n      await workspace.fetchAndSet(noteUri);\n\n      const embeddings = FoamEmbeddings.fromWorkspace(\n        workspace,\n        new MockProvider(),\n        true\n      );\n\n      await embeddings.updateResource(noteUri);\n      const originalEmbedding = embeddings.getEmbedding(noteUri);\n\n      // Update the content of the note to simulate a change\n      datastore.set(noteUri, '# Note\\n\\nDifferent content that is much longer');\n\n      // Trigger workspace update event\n      await workspace.fetchAndSet(noteUri);\n\n      // Wait for automatic update\n      await waitForExpect(\n        () => {\n          const newEmbedding = embeddings.getEmbedding(noteUri);\n          expect(newEmbedding).not.toEqual(originalEmbedding);\n        },\n        1000,\n        50\n      );\n\n      embeddings.dispose();\n      workspace.dispose();\n    });\n\n    it('should automatically remove embedding when resource is deleted', async () => {\n      const datastore = new InMemoryDataStore();\n      const workspace = createTestWorkspace(ROOT, datastore);\n      const noteUri = URI.parse('/note.md', 'file');\n\n      datastore.set(noteUri, '# Note\\n\\nContent');\n      await workspace.fetchAndSet(noteUri);\n\n      const embeddings = FoamEmbeddings.fromWorkspace(\n        workspace,\n        new MockProvider(),\n        true\n      );\n\n      await embeddings.updateResource(noteUri);\n      expect(embeddings.getEmbedding(noteUri)).not.toBeNull();\n\n      workspace.delete(noteUri);\n\n      // Give it a moment to process\n      await new Promise(resolve => setTimeout(resolve, 50));\n\n      expect(embeddings.getEmbedding(noteUri)).toBeNull();\n\n      embeddings.dispose();\n      workspace.dispose();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/model/embeddings.ts",
    "content": "import { Emitter } from '../../core/common/event';\nimport { IDisposable } from '../../core/common/lifecycle';\nimport { Logger } from '../../core/utils/log';\nimport { hash } from '../../core/utils';\nimport { EmbeddingProvider, Embedding } from '../services/embedding-provider';\nimport { EmbeddingCache } from './embedding-cache';\nimport {\n  ProgressCallback,\n  CancellationToken,\n  CancellationError,\n} from '../../core/services/progress';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { URI } from '../../core/model/uri';\n\n/**\n * Represents a similar resource with its similarity score\n */\nexport interface SimilarResource {\n  uri: URI;\n  similarity: number;\n}\n\n/**\n * Context information for embedding progress\n */\nexport interface EmbeddingProgressContext {\n  /** URI of the current resource */\n  uri: URI;\n  /** Title of the current resource */\n  title: string;\n}\n\n/**\n * Manages embeddings for all resources in the workspace\n */\nexport class FoamEmbeddings implements IDisposable {\n  /**\n   * Maps resource URIs to their embeddings\n   */\n  private embeddings: Map<string, Embedding> = new Map();\n\n  private onDidUpdateEmitter = new Emitter<void>();\n  onDidUpdate = this.onDidUpdateEmitter.event;\n\n  /**\n   * List of disposables to destroy with the embeddings\n   */\n  private disposables: IDisposable[] = [];\n\n  constructor(\n    private readonly workspace: FoamWorkspace,\n    private readonly provider: EmbeddingProvider,\n    private readonly cache?: EmbeddingCache\n  ) {}\n\n  /**\n   * Get the embedding for a resource\n   * @param uri The URI of the resource\n   * @returns The embedding vector, or null if not found\n   */\n  public getEmbedding(uri: URI): number[] | null {\n    const embedding = this.embeddings.get(uri.path);\n    return embedding ? embedding.vector : null;\n  }\n\n  /**\n   * Check if embeddings are available\n   * @returns true if at least one embedding exists\n   */\n  public hasEmbeddings(): boolean {\n    return this.embeddings.size > 0;\n  }\n\n  /**\n   * Get the number of embeddings\n   * @returns The count of embeddings\n   */\n  public size(): number {\n    return this.embeddings.size;\n  }\n\n  /**\n   * Find similar resources to a given resource\n   * @param uri The URI of the target resource\n   * @param topK The number of similar resources to return\n   * @returns Array of similar resources sorted by similarity (highest first)\n   */\n  public getSimilar(uri: URI, topK: number = 10): SimilarResource[] {\n    const targetEmbedding = this.getEmbedding(uri);\n    if (!targetEmbedding) {\n      return [];\n    }\n\n    const similarities: SimilarResource[] = [];\n\n    for (const [path, embedding] of this.embeddings.entries()) {\n      // Skip self\n      if (path === uri.path) {\n        continue;\n      }\n\n      const similarity = this.cosineSimilarity(\n        targetEmbedding,\n        embedding.vector\n      );\n      similarities.push({\n        uri: URI.file(path),\n        similarity,\n      });\n    }\n\n    // Sort by similarity (highest first) and take top K\n    similarities.sort((a, b) => b.similarity - a.similarity);\n    return similarities.slice(0, topK);\n  }\n\n  /**\n   * Calculate cosine similarity between two vectors\n   * @param a First vector\n   * @param b Second vector\n   * @returns Similarity score between -1 and 1 (higher is more similar)\n   */\n  public cosineSimilarity(a: number[], b: number[]): number {\n    if (a.length !== b.length) {\n      throw new Error('Vectors must have the same length');\n    }\n\n    let dotProduct = 0;\n    let normA = 0;\n    let normB = 0;\n\n    for (let i = 0; i < a.length; i++) {\n      dotProduct += a[i] * b[i];\n      normA += a[i] * a[i];\n      normB += b[i] * b[i];\n    }\n\n    const denominator = Math.sqrt(normA) * Math.sqrt(normB);\n    if (denominator === 0) {\n      return 0;\n    }\n\n    return dotProduct / denominator;\n  }\n\n  /**\n   * Update embeddings for a single resource\n   * @param uri The URI of the resource to update\n   * @returns The embedding vector, or null if not found/not processed\n   */\n  public async updateResource(uri: URI): Promise<Embedding | null> {\n    const resource = this.workspace.find(uri);\n    if (!resource) {\n      // Resource deleted, remove embedding\n      this.embeddings.delete(uri.path);\n      if (this.cache) {\n        this.cache.del(uri);\n      }\n      this.onDidUpdateEmitter.fire();\n      return null;\n    }\n\n    // Skip non-note resources (attachments)\n    if (resource.type !== 'note') {\n      return null;\n    }\n\n    try {\n      const content = await this.workspace.readAsMarkdown(resource.uri);\n      const text = this.prepareTextForEmbedding(resource.title, content);\n      const textChecksum = hash(text);\n\n      // Check cache if available\n      if (this.cache && this.cache.has(uri)) {\n        const cached = this.cache.get(uri);\n        if (cached.checksum === textChecksum) {\n          Logger.debug(\n            `Skipping embedding for ${uri.toFsPath()} - content unchanged`\n          );\n          // Use cached embedding\n          const embedding: Embedding = {\n            vector: cached.embedding,\n            createdAt: Date.now(),\n          };\n          this.embeddings.set(uri.path, embedding);\n          return embedding;\n        }\n      }\n\n      // Generate new embedding\n      const vector = await this.provider.embed(text);\n\n      const embedding: Embedding = {\n        vector,\n        createdAt: Date.now(),\n      };\n      this.embeddings.set(uri.path, embedding);\n\n      // Update cache\n      if (this.cache) {\n        this.cache.set(uri, {\n          checksum: textChecksum,\n          embedding: vector,\n        });\n      }\n\n      this.onDidUpdateEmitter.fire();\n      return embedding;\n    } catch (error) {\n      Logger.error(`Failed to update embedding for ${uri.toFsPath()}`, error);\n      return null;\n    }\n  }\n\n  /**\n   * Update embeddings for all notes, processing only missing or stale ones\n   * @param onProgress Optional callback to report progress\n   * @param cancellationToken Optional token to cancel the operation\n   * @returns Promise that resolves when all embeddings are updated\n   * @throws CancellationError if the operation is cancelled\n   */\n  public async update(\n    onProgress?: ProgressCallback<EmbeddingProgressContext>,\n    cancellationToken?: CancellationToken\n  ): Promise<void> {\n    const start = Date.now();\n\n    // Filter to only process notes (not attachments)\n    const allResources = Array.from(this.workspace.resources());\n    const resources = allResources.filter(r => r.type === 'note');\n\n    Logger.info(\n      `Building embeddings for ${resources.length} notes (${allResources.length} total resources)...`\n    );\n\n    let skipped = 0;\n    let generated = 0;\n    let reused = 0;\n\n    // Process embeddings sequentially to avoid overwhelming the service\n    for (let i = 0; i < resources.length; i++) {\n      // Check for cancellation\n      if (cancellationToken?.isCancellationRequested) {\n        Logger.info(\n          `Embedding build cancelled. Processed ${i}/${resources.length} notes.`\n        );\n        throw new CancellationError('Embedding build cancelled');\n      }\n\n      const resource = resources[i];\n\n      onProgress?.({\n        current: i + 1,\n        total: resources.length,\n        context: {\n          uri: resource.uri,\n          title: resource.title,\n        },\n      });\n\n      try {\n        const content = await this.workspace.readAsMarkdown(resource.uri);\n        const text = this.prepareTextForEmbedding(resource.title, content);\n        const textChecksum = hash(text);\n\n        // Check cache if available\n        if (this.cache && this.cache.has(resource.uri)) {\n          const cached = this.cache.get(resource.uri);\n          if (cached.checksum === textChecksum) {\n            // Check if we already have this embedding in memory\n            const existing = this.embeddings.get(resource.uri.path);\n            if (existing) {\n              // Already have current embedding, skip\n              reused++;\n              continue;\n            }\n\n            // Restore from cache\n            this.embeddings.set(resource.uri.path, {\n              vector: cached.embedding,\n              createdAt: Date.now(),\n            });\n            skipped++;\n            continue;\n          }\n        }\n\n        // Generate new embedding\n        const vector = await this.provider.embed(text);\n        this.embeddings.set(resource.uri.path, {\n          vector,\n          createdAt: Date.now(),\n        });\n\n        // Update cache\n        if (this.cache) {\n          this.cache.set(resource.uri, {\n            checksum: textChecksum,\n            embedding: vector,\n          });\n        }\n\n        generated++;\n      } catch (error) {\n        Logger.error(\n          `Failed to generate embedding for ${resource.uri.toFsPath()}`,\n          error\n        );\n      }\n    }\n\n    const end = Date.now();\n    Logger.info(\n      `Embeddings update complete: ${generated} generated, ${skipped} from cache, ${reused} already current (${\n        this.embeddings.size\n      }/${resources.length} total) in ${end - start}ms`\n    );\n    this.onDidUpdateEmitter.fire();\n  }\n\n  /**\n   * Prepare text for embedding by combining title and content\n   * @param title The title of the note\n   * @param content The markdown content of the note\n   * @returns The combined text to embed\n   */\n  private prepareTextForEmbedding(title: string, content: string): string {\n    const parts: string[] = [];\n\n    if (title) {\n      parts.push(title);\n    }\n\n    if (content) {\n      parts.push(content);\n    }\n\n    return parts.join('\\n\\n');\n  }\n\n  /**\n   * Create FoamEmbeddings from a workspace\n   * @param workspace The workspace to generate embeddings for\n   * @param provider The embedding provider to use\n   * @param keepMonitoring Whether to automatically update embeddings when workspace changes\n   * @param cache Optional cache for storing embeddings\n   * @returns The FoamEmbeddings instance\n   */\n  public static fromWorkspace(\n    workspace: FoamWorkspace,\n    provider: EmbeddingProvider,\n    keepMonitoring: boolean = false,\n    cache?: EmbeddingCache\n  ): FoamEmbeddings {\n    const embeddings = new FoamEmbeddings(workspace, provider, cache);\n\n    if (keepMonitoring) {\n      // Update embeddings when resources change\n      embeddings.disposables.push(\n        workspace.onDidAdd(resource => {\n          embeddings.updateResource(resource.uri);\n        }),\n        workspace.onDidUpdate(({ new: resource }) => {\n          embeddings.updateResource(resource.uri);\n        }),\n        workspace.onDidDelete(resource => {\n          embeddings.embeddings.delete(resource.uri.path);\n          embeddings.onDidUpdateEmitter.fire();\n        })\n      );\n    }\n\n    return embeddings;\n  }\n\n  public dispose(): void {\n    this.onDidUpdateEmitter.dispose();\n    this.disposables.forEach(d => d.dispose());\n    this.disposables = [];\n    this.embeddings.clear();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/model/in-memory-embedding-cache.ts",
    "content": "import { URI } from '../../core/model/uri';\nimport { EmbeddingCache, EmbeddingCacheEntry } from './embedding-cache';\n\n/**\n * Simple in-memory implementation of embedding cache\n */\nexport class InMemoryEmbeddingCache implements EmbeddingCache {\n  private cache: Map<string, EmbeddingCacheEntry> = new Map();\n\n  get(uri: URI): EmbeddingCacheEntry {\n    return this.cache.get(uri.toString());\n  }\n\n  has(uri: URI): boolean {\n    return this.cache.has(uri.toString());\n  }\n\n  set(uri: URI, entry: EmbeddingCacheEntry): void {\n    this.cache.set(uri.toString(), entry);\n  }\n\n  del(uri: URI): void {\n    this.cache.delete(uri.toString());\n  }\n\n  clear(): void {\n    this.cache.clear();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/providers/ollama/ollama-provider.test.ts",
    "content": "import { Logger } from '../../../core/utils/log';\nimport {\n  OllamaEmbeddingProvider,\n  DEFAULT_OLLAMA_CONFIG,\n} from './ollama-provider';\n\nLogger.setLevel('error');\n\ndescribe('OllamaEmbeddingProvider', () => {\n  const originalFetch = global.fetch;\n  beforeEach(() => {\n    global.fetch = jest.fn();\n    jest.clearAllMocks();\n    jest.useFakeTimers();\n  });\n\n  afterEach(() => {\n    jest.useRealTimers();\n    global.fetch = originalFetch;\n  });\n\n  describe('constructor', () => {\n    it('should use default config when no config provided', () => {\n      const provider = new OllamaEmbeddingProvider();\n      const config = provider.getConfig();\n\n      expect(config.url).toBe(DEFAULT_OLLAMA_CONFIG.url);\n      expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);\n      expect(config.timeout).toBe(DEFAULT_OLLAMA_CONFIG.timeout);\n    });\n\n    it('should merge custom config with defaults', () => {\n      const provider = new OllamaEmbeddingProvider({\n        url: 'http://custom:11434',\n      });\n      const config = provider.getConfig();\n\n      expect(config.url).toBe('http://custom:11434');\n      expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);\n    });\n  });\n\n  describe('getProviderInfo', () => {\n    it('should return provider information', () => {\n      const provider = new OllamaEmbeddingProvider();\n      const info = provider.getProviderInfo();\n\n      expect(info.name).toBe('Ollama');\n      expect(info.type).toBe('local');\n      expect(info.model.name).toBe('nomic-embed-text');\n      expect(info.model.dimensions).toBe(768);\n      expect(info.endpoint).toBe('http://localhost:11434');\n      expect(info.description).toBe('Local embedding provider using Ollama');\n      expect(info.metadata).toEqual({ timeout: 30000 });\n    });\n\n    it('should return custom model name when configured', () => {\n      const provider = new OllamaEmbeddingProvider({\n        model: 'custom-model',\n      });\n      const info = provider.getProviderInfo();\n\n      expect(info.model.name).toBe('custom-model');\n    });\n\n    it('should return custom endpoint when configured', () => {\n      const provider = new OllamaEmbeddingProvider({\n        url: 'http://custom:8080',\n      });\n      const info = provider.getProviderInfo();\n\n      expect(info.endpoint).toBe('http://custom:8080');\n    });\n  });\n\n  describe('embed', () => {\n    it('should successfully generate embeddings', async () => {\n      const mockEmbedding = new Array(768).fill(0.1);\n      (global.fetch as jest.Mock).mockResolvedValueOnce({\n        ok: true,\n        json: async () => ({ embeddings: [mockEmbedding] }),\n      });\n\n      const provider = new OllamaEmbeddingProvider();\n      const result = await provider.embed('test text');\n\n      expect(result).toEqual(mockEmbedding);\n      expect(global.fetch).toHaveBeenCalledWith(\n        'http://localhost:11434/api/embed',\n        expect.objectContaining({\n          method: 'POST',\n          headers: { 'Content-Type': 'application/json' },\n          body: JSON.stringify({\n            model: 'nomic-embed-text',\n            input: ['test text'],\n          }),\n        })\n      );\n    });\n\n    it('should throw error on non-ok response', async () => {\n      (global.fetch as jest.Mock).mockResolvedValueOnce({\n        ok: false,\n        status: 500,\n        text: async () => 'Internal server error',\n      });\n\n      const provider = new OllamaEmbeddingProvider();\n\n      await expect(provider.embed('test')).rejects.toThrow(\n        'AI service error (500)'\n      );\n    });\n\n    it('should throw error on connection refused', async () => {\n      (global.fetch as jest.Mock).mockRejectedValueOnce(\n        new Error('fetch failed')\n      );\n\n      const provider = new OllamaEmbeddingProvider();\n\n      await expect(provider.embed('test')).rejects.toThrow(\n        'Cannot connect to Ollama'\n      );\n    });\n\n    it('should timeout after configured duration', async () => {\n      (global.fetch as jest.Mock).mockImplementationOnce(\n        (_url, options) =>\n          new Promise((_resolve, reject) => {\n            // Simulate abort signal being triggered\n            options.signal.addEventListener('abort', () => {\n              const error = new Error('The operation was aborted');\n              error.name = 'AbortError';\n              reject(error);\n            });\n          })\n      );\n\n      const provider = new OllamaEmbeddingProvider({ timeout: 1000 });\n      const embedPromise = provider.embed('test');\n\n      // Fast-forward time to trigger timeout\n      jest.advanceTimersByTime(1001);\n\n      await expect(embedPromise).rejects.toThrow('AI service took too long');\n    });\n  });\n\n  describe('isAvailable', () => {\n    it('should return true when Ollama is available', async () => {\n      (global.fetch as jest.Mock).mockResolvedValueOnce({\n        ok: true,\n      });\n\n      const provider = new OllamaEmbeddingProvider();\n      const result = await provider.isAvailable();\n\n      expect(result).toBe(true);\n      expect(global.fetch).toHaveBeenCalledWith(\n        'http://localhost:11434/api/tags',\n        expect.objectContaining({\n          method: 'GET',\n        })\n      );\n    });\n\n    it('should return false when Ollama is not available', async () => {\n      (global.fetch as jest.Mock).mockRejectedValueOnce(\n        new Error('Connection refused')\n      );\n\n      const provider = new OllamaEmbeddingProvider();\n      const result = await provider.isAvailable();\n\n      expect(result).toBe(false);\n    });\n\n    it('should return false when Ollama returns non-ok status', async () => {\n      (global.fetch as jest.Mock).mockResolvedValueOnce({\n        ok: false,\n        status: 404,\n      });\n\n      const provider = new OllamaEmbeddingProvider();\n      const result = await provider.isAvailable();\n\n      expect(result).toBe(false);\n    });\n\n    it('should timeout quickly (5s) when checking availability', async () => {\n      (global.fetch as jest.Mock).mockImplementationOnce(\n        (_url, options) =>\n          new Promise((_resolve, reject) => {\n            // Simulate abort signal being triggered\n            options.signal.addEventListener('abort', () => {\n              const error = new Error('The operation was aborted');\n              error.name = 'AbortError';\n              reject(error);\n            });\n          })\n      );\n\n      const provider = new OllamaEmbeddingProvider();\n      const availabilityPromise = provider.isAvailable();\n\n      // Fast-forward time to trigger timeout (5s for availability check)\n      jest.advanceTimersByTime(5001);\n\n      const result = await availabilityPromise;\n      expect(result).toBe(false);\n    });\n  });\n});\n\ndescribe('OllamaEmbeddingProvider - Integration', () => {\n  const provider = new OllamaEmbeddingProvider();\n\n  it('should handle text with unicode characters and emojis', async () => {\n    if (!(await provider.isAvailable())) {\n      console.warn('Ollama is not available, skipping test');\n      return;\n    }\n    const text = 'Task completed ✔ 🚀: All systems go! 🌟';\n    const embedding = await provider.embed(text);\n\n    expect(embedding).toBeDefined();\n    expect(Array.isArray(embedding)).toBe(true);\n    expect(embedding.length).toBe(768); // nomic-embed-text dimension\n    expect(embedding.every(n => typeof n === 'number')).toBe(true);\n  });\n\n  it('should handle text with various unicode characters', async () => {\n    if (!(await provider.isAvailable())) {\n      console.warn('Ollama is not available, skipping test');\n      return;\n    }\n    const text = 'Hello 🌍 with émojis and spëcial çharacters • bullet ✓ check';\n    const embedding = await provider.embed(text);\n\n    expect(embedding).toBeDefined();\n    expect(Array.isArray(embedding)).toBe(true);\n    expect(embedding.length).toBe(768);\n  });\n\n  it('should handle text with combining unicode characters', async () => {\n    if (!(await provider.isAvailable())) {\n      console.warn('Ollama is not available, skipping test');\n      return;\n    }\n    // Test with combining diacriticals that could be represented differently\n    const text = 'café vs cafe\\u0301'; // Two ways to represent é\n    const embedding = await provider.embed(text);\n\n    expect(embedding).toBeDefined();\n    expect(Array.isArray(embedding)).toBe(true);\n    expect(embedding.length).toBe(768);\n  });\n\n  it('should handle empty text', async () => {\n    if (!(await provider.isAvailable())) {\n      console.warn('Ollama is not available, skipping test');\n      return;\n    }\n    const text = '';\n    const embedding = await provider.embed(text);\n\n    expect(embedding).toBeDefined();\n    expect(Array.isArray(embedding)).toBe(true);\n    // Note: Ollama returns empty array for empty text\n    expect(embedding.length).toBeGreaterThanOrEqual(0);\n  });\n\n  it.each([10, 50, 60, 100, 300])(\n    'should handle text of various lengths',\n    async length => {\n      if (!(await provider.isAvailable())) {\n        console.warn('Ollama is not available, skipping test');\n        return;\n      }\n      const text = 'Lorem ipsum dolor sit amet. '.repeat(length);\n      try {\n        const embedding = await provider.embed(text);\n        expect(embedding).toBeDefined();\n        expect(Array.isArray(embedding)).toBe(true);\n        expect(embedding.length).toBe(768);\n      } catch (error) {\n        throw new Error(\n          `Embedding failed for text of length ${text.length}: ${error}`\n        );\n      }\n    }\n  );\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/providers/ollama/ollama-provider.ts",
    "content": "import {\n  EmbeddingProvider,\n  EmbeddingProviderInfo,\n} from '../../services/embedding-provider';\nimport { Logger } from '../../../core/utils/log';\n\n/**\n * Configuration for Ollama embedding provider\n */\nexport interface OllamaConfig {\n  /** Base URL for Ollama API (default: http://localhost:11434) */\n  url: string;\n  /** Model name to use for embeddings (default: nomic-embed-text) */\n  model: string;\n  /** Request timeout in milliseconds (default: 30000) */\n  timeout: number;\n}\n\n/**\n * Default configuration for Ollama\n */\nexport const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {\n  url: 'http://localhost:11434',\n  model: 'nomic-embed-text',\n  timeout: 30000,\n};\n\n/**\n * Embedding provider that uses Ollama for generating embeddings\n */\nexport class OllamaEmbeddingProvider implements EmbeddingProvider {\n  private config: OllamaConfig;\n\n  constructor(config: Partial<OllamaConfig> = {}) {\n    this.config = { ...DEFAULT_OLLAMA_CONFIG, ...config };\n  }\n\n  /**\n   * Generate an embedding for the given text\n   */\n  async embed(text: string): Promise<number[]> {\n    // normalize text to suitable input (format and size)\n    // TODO we should better handle long texts by chunking them and averaging embeddings\n    const input = text.substring(0, 6000).normalize();\n\n    try {\n      const controller = new AbortController();\n      const timeoutId = setTimeout(\n        () => controller.abort(),\n        this.config.timeout\n      );\n\n      const response = await fetch(`${this.config.url}/api/embed`, {\n        method: 'POST',\n        headers: {\n          'Content-Type': 'application/json',\n        },\n        body: JSON.stringify({\n          model: this.config.model,\n          input: [input],\n        }),\n        signal: controller.signal,\n      });\n\n      clearTimeout(timeoutId);\n\n      if (!response.ok) {\n        const errorText = await response.text();\n        throw new Error(`AI service error (${response.status}): ${errorText}`);\n      }\n\n      const data = await response.json();\n      if (data.embeddings == null) {\n        throw new Error(\n          `Invalid response from AI service: ${JSON.stringify(data)}`\n        );\n      }\n      return data.embeddings[0];\n    } catch (error) {\n      if (error instanceof Error) {\n        if (error.name === 'AbortError') {\n          throw new Error(\n            'AI service took too long to respond. It may be busy processing another request.'\n          );\n        }\n        if (\n          error.message.includes('fetch') ||\n          error.message.includes('ECONNREFUSED')\n        ) {\n          throw new Error(\n            `Cannot connect to Ollama at ${this.config.url}. Make sure Ollama is installed and running.`\n          );\n        }\n      }\n      throw error;\n    }\n  }\n\n  /**\n   * Check if Ollama is available and the model is accessible\n   */\n  async isAvailable(): Promise<boolean> {\n    try {\n      const controller = new AbortController();\n      const timeoutId = setTimeout(() => controller.abort(), 5000);\n\n      // Try to reach the Ollama API\n      const response = await fetch(`${this.config.url}/api/tags`, {\n        method: 'GET',\n        signal: controller.signal,\n      });\n\n      clearTimeout(timeoutId);\n\n      if (!response.ok) {\n        Logger.warn(\n          `Ollama API returned status ${response.status} when checking availability`\n        );\n        return false;\n      }\n\n      return true;\n    } catch (error) {\n      Logger.debug(\n        `Ollama not available at ${this.config.url}: ${\n          error instanceof Error ? error.message : 'Unknown error'\n        }`\n      );\n      return false;\n    }\n  }\n\n  /**\n   * Get provider information including model details\n   */\n  getProviderInfo(): EmbeddingProviderInfo {\n    return {\n      name: 'Ollama',\n      type: 'local',\n      model: {\n        name: this.config.model,\n        // nomic-embed-text produces 768-dimensional embeddings\n        dimensions: 768,\n      },\n      description: 'Local embedding provider using Ollama',\n      endpoint: this.config.url,\n      metadata: {\n        timeout: this.config.timeout,\n      },\n    };\n  }\n\n  /**\n   * Get current configuration\n   */\n  getConfig(): OllamaConfig {\n    return { ...this.config };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/services/embedding-provider.ts",
    "content": "/**\n * Information about an embedding provider and its model\n */\nexport interface EmbeddingProviderInfo {\n  /** Human-readable name of the provider (e.g., \"Ollama\", \"OpenAI\") */\n  name: string;\n\n  /** Type of provider */\n  type: 'local' | 'remote';\n\n  /** Model information */\n  model: {\n    /** Model name (e.g., \"nomic-embed-text\", \"text-embedding-3-small\") */\n    name: string;\n    /** Vector dimensions */\n    dimensions: number;\n  };\n\n  /** Optional description of the provider */\n  description?: string;\n\n  /** Backend endpoint/URL if applicable */\n  endpoint?: string;\n\n  /** Additional provider-specific metadata */\n  metadata?: Record<string, unknown>;\n}\n\n/**\n * Provider interface for generating text embeddings\n */\nexport interface EmbeddingProvider {\n  /**\n   * Generate an embedding vector for the given text\n   * @param text The text to embed\n   * @returns A promise that resolves to the embedding vector\n   */\n  embed(text: string): Promise<number[]>;\n\n  /**\n   * Check if the embedding service is available and ready to use\n   * @returns A promise that resolves to true if available, false otherwise\n   */\n  isAvailable(): Promise<boolean>;\n\n  /**\n   * Get information about the provider and its model\n   * @returns Provider metadata including name, type, model info, and configuration\n   */\n  getProviderInfo(): EmbeddingProviderInfo;\n}\n\n/**\n * Represents a text embedding with metadata\n */\nexport interface Embedding {\n  /** The embedding vector */\n  vector: number[];\n  /** Timestamp when the embedding was created */\n  createdAt: number;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/services/noop-embedding-provider.ts",
    "content": "import { EmbeddingProvider, EmbeddingProviderInfo } from './embedding-provider';\n\n/**\n * A no-op embedding provider that does nothing.\n * Used when no real embedding provider is available.\n */\nexport class NoOpEmbeddingProvider implements EmbeddingProvider {\n  async embed(_text: string): Promise<number[]> {\n    return [];\n  }\n\n  async isAvailable(): Promise<boolean> {\n    return false;\n  }\n\n  getProviderInfo(): EmbeddingProviderInfo {\n    return {\n      name: 'None',\n      type: 'local',\n      model: {\n        name: 'none',\n        dimensions: 0,\n      },\n      description: 'No embedding provider configured',\n    };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/vscode/commands/build-embeddings.spec.ts",
    "content": "/* @unit-ready */\nimport * as vscode from 'vscode';\nimport {\n  cleanWorkspace,\n  createFile,\n  deleteFile,\n  waitForNoteInFoamWorkspace,\n} from '../../../test/test-utils-vscode';\nimport { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';\n\ndescribe('build-embeddings command', () => {\n  it('should complete successfully with no notes to analyze', async () => {\n    await cleanWorkspace(5000);\n\n    const showInfoSpy = jest\n      .spyOn(vscode.window, 'showInformationMessage')\n      .mockResolvedValue(undefined);\n\n    const result = await vscode.commands.executeCommand<\n      'complete' | 'cancelled' | 'error'\n    >(BUILD_EMBEDDINGS_COMMAND.command);\n\n    expect(result).toBe('complete');\n    expect(showInfoSpy).toHaveBeenCalledWith(\n      expect.stringContaining('No notes found')\n    );\n\n    showInfoSpy.mockRestore();\n  });\n\n  it('should analyze notes and report completion', async () => {\n    await cleanWorkspace(5000);\n    const note1 = await createFile('# Note 1\\nContent here', ['note1.md']);\n    const note2 = await createFile('# Note 2\\nMore content', ['note2.md']);\n\n    await waitForNoteInFoamWorkspace(note1.uri);\n    await waitForNoteInFoamWorkspace(note2.uri);\n\n    const showInfoSpy = jest\n      .spyOn(vscode.window, 'showInformationMessage')\n      .mockResolvedValue(undefined);\n\n    const result = await vscode.commands.executeCommand<\n      'complete' | 'cancelled' | 'error'\n    >(BUILD_EMBEDDINGS_COMMAND.command);\n\n    expect(result).toBe('complete');\n    expect(showInfoSpy).toHaveBeenCalledWith(\n      expect.stringMatching(/Analyzed.*2/)\n    );\n\n    showInfoSpy.mockRestore();\n    await deleteFile(note1.uri);\n    await deleteFile(note2.uri);\n  });\n\n  it('should return cancelled status when operation is cancelled', async () => {\n    await cleanWorkspace(5000);\n    const note1 = await createFile('# Note 1\\nContent', ['note1.md']);\n    await waitForNoteInFoamWorkspace(note1.uri);\n\n    const tokenSource = new vscode.CancellationTokenSource();\n\n    const withProgressSpy = jest\n      .spyOn(vscode.window, 'withProgress')\n      .mockImplementation(async (options, task) => {\n        const progress = { report: () => {} };\n        // Cancel immediately\n        tokenSource.cancel();\n        return await task(progress, tokenSource.token);\n      });\n\n    const showInfoSpy = jest\n      .spyOn(vscode.window, 'showInformationMessage')\n      .mockResolvedValue(undefined);\n\n    const result = await vscode.commands.executeCommand<\n      'complete' | 'cancelled' | 'error'\n    >(BUILD_EMBEDDINGS_COMMAND.command);\n\n    expect(result).toBe('cancelled');\n    expect(showInfoSpy).toHaveBeenCalledWith(\n      expect.stringContaining('cancelled')\n    );\n\n    withProgressSpy.mockRestore();\n    showInfoSpy.mockRestore();\n    await deleteFile(note1.uri);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/vscode/commands/build-embeddings.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../../core/model/foam';\nimport { CancellationError } from '../../../core/services/progress';\nimport { TaskDeduplicator } from '../../../core/utils/task-deduplicator';\nimport { FoamWorkspace } from '../../../core/model/workspace';\nimport { FoamEmbeddings } from '../../../ai/model/embeddings';\n\nexport const BUILD_EMBEDDINGS_COMMAND = {\n  command: 'foam-vscode.build-embeddings',\n  title: 'Foam: Analyze Notes with AI',\n};\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  // Deduplicate concurrent executions\n  const deduplicator = new TaskDeduplicator<\n    'complete' | 'cancelled' | 'error'\n  >();\n\n  context.subscriptions.push(\n    vscode.commands.registerCommand(\n      BUILD_EMBEDDINGS_COMMAND.command,\n      async () => {\n        return await deduplicator.run(\n          () => buildEmbeddings(foam.workspace, foam.embeddings),\n          () => {\n            vscode.window.showInformationMessage(\n              'Note analysis is already in progress - waiting for it to complete'\n            );\n          }\n        );\n      }\n    )\n  );\n}\n\nasync function buildEmbeddings(\n  workspace: FoamWorkspace,\n  embeddings: FoamEmbeddings\n): Promise<'complete' | 'cancelled' | 'error'> {\n  const notesCount = workspace.list().filter(r => r.type === 'note').length;\n\n  if (notesCount === 0) {\n    vscode.window.showInformationMessage('No notes found in workspace');\n    return 'complete';\n  }\n\n  // Show progress notification\n  return await vscode.window.withProgress(\n    {\n      location: vscode.ProgressLocation.Window,\n      title: 'Analyzing notes',\n      cancellable: true,\n    },\n    async (progress, token) => {\n      try {\n        await embeddings.update(progressInfo => {\n          const title = progressInfo.context?.title || 'Processing...';\n          const increment = (1 / progressInfo.total) * 100;\n          progress.report({\n            message: `${progressInfo.current}/${progressInfo.total} - ${title}`,\n            increment: increment,\n          });\n        }, token);\n\n        vscode.window.showInformationMessage(\n          `✓ Analyzed ${embeddings.size()} of ${notesCount} notes`\n        );\n        return 'complete';\n      } catch (error) {\n        if (error instanceof CancellationError) {\n          vscode.window.showInformationMessage(\n            'Analysis cancelled. Run the command again to continue where you left off.'\n          );\n          return 'cancelled';\n        }\n\n        const errorMessage =\n          error instanceof Error ? error.message : 'Unknown error';\n\n        vscode.window.showErrorMessage(\n          `Failed to analyze notes: ${errorMessage}`\n        );\n        return 'error';\n      }\n    }\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/vscode/commands/show-similar-notes.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../../core/model/foam';\nimport { fromVsCodeUri, toVsCodeUri } from '../../../utils/vsc-utils';\nimport { URI } from '../../../core/model/uri';\nimport { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';\n\nexport const SHOW_SIMILAR_NOTES_COMMAND = {\n  command: 'foam-vscode.show-similar-notes',\n  title: 'Foam: Show Similar Notes',\n};\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    vscode.commands.registerCommand(\n      SHOW_SIMILAR_NOTES_COMMAND.command,\n      async () => {\n        await showSimilarNotes(foam);\n      }\n    )\n  );\n}\n\nasync function showSimilarNotes(foam: Foam): Promise<void> {\n  // Get the active editor\n  const editor = vscode.window.activeTextEditor;\n  if (!editor) {\n    vscode.window.showInformationMessage('Please open a note first');\n    return;\n  }\n\n  // Get the URI of the active document\n  const uri = fromVsCodeUri(editor.document.uri);\n\n  // Check if the resource exists in workspace\n  const resource = foam.workspace.find(uri);\n  if (!resource) {\n    vscode.window.showInformationMessage('This file is not a note');\n    return;\n  }\n\n  // Ensure embeddings are up-to-date (incremental update)\n  const status: 'complete' | 'error' | 'cancelled' =\n    await vscode.commands.executeCommand(BUILD_EMBEDDINGS_COMMAND.command);\n\n  if (status !== 'complete') {\n    return;\n  }\n\n  // Check if embedding exists for this resource\n  const embedding = foam.embeddings.getEmbedding(uri);\n  if (!embedding) {\n    vscode.window.showInformationMessage(\n      'This note hasn\\'t been analyzed yet. Make sure the AI service is running and try the \"Analyze Notes with AI\" command.'\n    );\n    return;\n  }\n\n  // Get similar notes\n  const similar = foam.embeddings.getSimilar(uri, 10);\n\n  if (similar.length === 0) {\n    vscode.window.showInformationMessage('No similar notes found');\n    return;\n  }\n\n  // Create quick pick items\n  const items: vscode.QuickPickItem[] = similar.map(item => {\n    const resource = foam.workspace.find(item.uri);\n    const title = resource?.title || item.uri.getBasename();\n    const similarityPercent = (item.similarity * 100).toFixed(1);\n\n    return {\n      label: `$(file) ${title}`,\n      description: `${similarityPercent}% similar`,\n      detail: item.uri.toFsPath(),\n      uri: item.uri,\n    } as vscode.QuickPickItem & { uri: URI };\n  });\n\n  // Show quick pick\n  const selected = await vscode.window.showQuickPick(items, {\n    placeHolder: 'Select a similar note to open',\n    matchOnDescription: true,\n    matchOnDetail: true,\n  });\n\n  if (selected) {\n    const selectedUri = (selected as any).uri as URI;\n    const doc = await vscode.workspace.openTextDocument(\n      toVsCodeUri(selectedUri)\n    );\n    await vscode.window.showTextDocument(doc);\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/ai/vscode/panels/related-notes.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../../core/model/foam';\nimport { FoamWorkspace } from '../../../core/model/workspace';\nimport { URI } from '../../../core/model/uri';\nimport { fromVsCodeUri } from '../../../utils/vsc-utils';\nimport { BaseTreeProvider } from '../../../features/panels/utils/base-tree-provider';\nimport { ResourceTreeItem } from '../../../features/panels/utils/tree-view-utils';\nimport { FoamEmbeddings } from '../../../ai/model/embeddings';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  const provider = new RelatedNotesTreeDataProvider(\n    foam.workspace,\n    foam.embeddings,\n    context.globalState\n  );\n\n  const treeView = vscode.window.createTreeView('foam-vscode.related-notes', {\n    treeDataProvider: provider,\n    showCollapseAll: false,\n  });\n\n  const updateTreeView = async () => {\n    const activeEditor = vscode.window.activeTextEditor;\n    provider.target = activeEditor\n      ? fromVsCodeUri(activeEditor.document.uri)\n      : undefined;\n    await provider.refresh();\n\n    // Update context for conditional viewsWelcome messages\n    vscode.commands.executeCommand(\n      'setContext',\n      'foam.relatedNotes.state',\n      provider.getState()\n    );\n  };\n\n  updateTreeView();\n\n  context.subscriptions.push(\n    provider,\n    treeView,\n    foam.embeddings.onDidUpdate(() => updateTreeView()),\n    vscode.window.onDidChangeActiveTextEditor(() => updateTreeView()),\n    provider.onDidChangeTreeData(() => {\n      treeView.title = `Related Notes (${provider.nValues})`;\n    })\n  );\n}\n\nexport class RelatedNotesTreeDataProvider extends BaseTreeProvider<vscode.TreeItem> {\n  public target?: URI = undefined;\n  public nValues = 0;\n  private relatedNotes: Array<{ uri: URI; similarity: number }> = [];\n  private currentNoteHasEmbedding = false;\n\n  constructor(\n    private workspace: FoamWorkspace,\n    private embeddings: FoamEmbeddings,\n    public state: vscode.Memento\n  ) {\n    super();\n  }\n\n  async refresh(): Promise<void> {\n    const uri = this.target;\n\n    // Clear if no target or target is not a note\n    if (!uri) {\n      this.relatedNotes = [];\n      this.nValues = 0;\n      this.currentNoteHasEmbedding = false;\n      super.refresh();\n      return;\n    }\n\n    const resource = this.workspace.find(uri);\n    if (!resource || resource.type !== 'note') {\n      this.relatedNotes = [];\n      this.nValues = 0;\n      this.currentNoteHasEmbedding = false;\n      super.refresh();\n      return;\n    }\n\n    // Check if current note has an embedding\n    this.currentNoteHasEmbedding = this.embeddings.getEmbedding(uri) !== null;\n\n    // Get similar notes (user can click \"Build Embeddings\" button if needed)\n    const similar = this.embeddings.getSimilar(uri, 10);\n    this.relatedNotes = similar.filter(n => n.similarity > 0.6);\n    this.nValues = this.relatedNotes.length;\n    super.refresh();\n  }\n\n  async getChildren(item?: vscode.TreeItem): Promise<vscode.TreeItem[]> {\n    if (item) {\n      return [];\n    }\n\n    // If no related notes found, show appropriate message in viewsWelcome\n    // The empty array will trigger the viewsWelcome content\n    if (this.relatedNotes.length === 0) {\n      return [];\n    }\n\n    return this.relatedNotes\n      .map(({ uri, similarity }) => {\n        const resource = this.workspace.find(uri);\n        if (!resource) {\n          return null;\n        }\n\n        const item = new ResourceTreeItem(resource, this.workspace);\n        // Show similarity score as percentage in description\n        item.description = `${Math.round(similarity * 100)}%`;\n        return item;\n      })\n      .filter(item => item !== null) as ResourceTreeItem[];\n  }\n\n  /**\n   * Returns the current state of the related notes panel\n   */\n  public getState(): 'no-note' | 'no-embedding' | 'ready' {\n    if (!this.target) {\n      return 'no-note';\n    }\n    if (!this.currentNoteHasEmbedding) {\n      return 'no-embedding';\n    }\n    return 'ready';\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/.eslintrc.json",
    "content": "{\n  \"rules\": {\n    \"no-restricted-imports\": [\n      \"error\",\n      {\n        \"name\": \"vscode\",\n        \"message\": \"Core submodule must not depend on VS Code.\"\n      }\n    ]\n    // Ideally we would also prevent the core module from depending on other modules\n    // but I have been struggling to get it to work.\n    // For future reference, below are some configurations I think should achieve this\n    // (but I couldn't manage to get working).\n    //\n    // \"import/no-internal-modules\": [\n    //   \"error\",\n    //   {\n    //     \"allow\": [\"./src/core\"]\n    //   }\n    // ]\n    // \"import/no-restricted-paths\": [\n    //   \"error\",\n    //   {\n    //     \"zones\": [\n    //       {\n    //         \"target\": \"./src/core\",\n    //         \"from\": \"./src/(!core)\",\n    //         \"message\": \"Core module can't have outside dependencies.\"\n    //       }\n    //     ]\n    //   }\n    // ]\n    // \"import/no-relative-parent-imports\": \"error\"\n    // note:  https://github.com/import-js/eslint-plugin-import/issues/1610\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/cancellation.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nimport { Emitter, Event } from './event';\nimport { IDisposable } from './lifecycle';\n\nexport interface CancellationToken {\n  /**\n   * A flag signalling is cancellation has been requested.\n   */\n  readonly isCancellationRequested: boolean;\n\n  /**\n   * An event which fires when cancellation is requested. This event\n   * only ever fires `once` as cancellation can only happen once. Listeners\n   * that are registered after cancellation will be called (next event loop run),\n   * but also only once.\n   *\n   * @event\n   */\n  readonly onCancellationRequested: (\n    listener: (e: any) => any,\n    thisArgs?: any,\n    disposables?: IDisposable[]\n  ) => IDisposable;\n}\n\nconst shortcutEvent: Event<any> = Object.freeze(function (\n  callback,\n  context?\n): IDisposable {\n  const handle = setTimeout(callback.bind(context), 0);\n  return {\n    dispose() {\n      clearTimeout(handle);\n    },\n  };\n});\n\nexport namespace CancellationToken {\n  export function isCancellationToken(\n    thing: unknown\n  ): thing is CancellationToken {\n    if (\n      thing === CancellationToken.None ||\n      thing === CancellationToken.Cancelled\n    ) {\n      return true;\n    }\n    if (thing instanceof MutableToken) {\n      return true;\n    }\n    if (!thing || typeof thing !== 'object') {\n      return false;\n    }\n    return (\n      typeof (thing as CancellationToken).isCancellationRequested ===\n        'boolean' &&\n      typeof (thing as CancellationToken).onCancellationRequested === 'function'\n    );\n  }\n\n  export const None: CancellationToken = Object.freeze({\n    isCancellationRequested: false,\n    onCancellationRequested: Event.None,\n  });\n\n  export const Cancelled: CancellationToken = Object.freeze({\n    isCancellationRequested: true,\n    onCancellationRequested: shortcutEvent,\n  });\n}\n\nclass MutableToken implements CancellationToken {\n  private _isCancelled: boolean = false;\n  private _emitter: Emitter<any> | null = null;\n\n  public cancel() {\n    if (!this._isCancelled) {\n      this._isCancelled = true;\n      if (this._emitter) {\n        this._emitter.fire(undefined);\n        this.dispose();\n      }\n    }\n  }\n\n  get isCancellationRequested(): boolean {\n    return this._isCancelled;\n  }\n\n  get onCancellationRequested(): Event<any> {\n    if (this._isCancelled) {\n      return shortcutEvent;\n    }\n    if (!this._emitter) {\n      this._emitter = new Emitter<any>();\n    }\n    return this._emitter.event;\n  }\n\n  public dispose(): void {\n    if (this._emitter) {\n      this._emitter.dispose();\n      this._emitter = null;\n    }\n  }\n}\n\nexport class CancellationTokenSource {\n  private _token?: CancellationToken = undefined;\n  private _parentListener?: IDisposable = undefined;\n\n  constructor(parent?: CancellationToken) {\n    this._parentListener =\n      parent && parent.onCancellationRequested(this.cancel, this);\n  }\n\n  get token(): CancellationToken {\n    if (!this._token) {\n      // be lazy and create the token only when\n      // actually needed\n      this._token = new MutableToken();\n    }\n    return this._token;\n  }\n\n  cancel(): void {\n    if (!this._token) {\n      // save an object by returning the default\n      // cancelled token when cancellation happens\n      // before someone asks for the token\n      this._token = CancellationToken.Cancelled;\n    } else if (this._token instanceof MutableToken) {\n      // actually cancel\n      this._token.cancel();\n    }\n  }\n\n  dispose(cancel: boolean = false): void {\n    if (cancel) {\n      this.cancel();\n    }\n    if (this._parentListener) {\n      this._parentListener.dispose();\n    }\n    if (!this._token) {\n      // ensure to initialize with an empty token if we had none\n      this._token = CancellationToken.None;\n    } else if (this._token instanceof MutableToken) {\n      // actually dispose\n      this._token.dispose();\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/charCode.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\n// Names from https://blog.codinghorror.com/ascii-pronunciation-rules-for-programmers/\n\n/**\n * An inlined enum containing useful character codes (to be used with String.charCodeAt).\n * Please leave the const keyword such that it gets inlined when compiled to JavaScript!\n */\nexport const enum CharCode {\n  Null = 0,\n  /**\n   * The `\\b` character.\n   */\n  Backspace = 8,\n  /**\n   * The `\\t` character.\n   */\n  Tab = 9,\n  /**\n   * The `\\n` character.\n   */\n  LineFeed = 10,\n  /**\n   * The `\\r` character.\n   */\n  CarriageReturn = 13,\n  Space = 32,\n  /**\n   * The `!` character.\n   */\n  ExclamationMark = 33,\n  /**\n   * The `\"` character.\n   */\n  DoubleQuote = 34,\n  /**\n   * The `#` character.\n   */\n  Hash = 35,\n  /**\n   * The `$` character.\n   */\n  DollarSign = 36,\n  /**\n   * The `%` character.\n   */\n  PercentSign = 37,\n  /**\n   * The `&` character.\n   */\n  Ampersand = 38,\n  /**\n   * The `'` character.\n   */\n  SingleQuote = 39,\n  /**\n   * The `(` character.\n   */\n  OpenParen = 40,\n  /**\n   * The `)` character.\n   */\n  CloseParen = 41,\n  /**\n   * The `*` character.\n   */\n  Asterisk = 42,\n  /**\n   * The `+` character.\n   */\n  Plus = 43,\n  /**\n   * The `,` character.\n   */\n  Comma = 44,\n  /**\n   * The `-` character.\n   */\n  Dash = 45,\n  /**\n   * The `.` character.\n   */\n  Period = 46,\n  /**\n   * The `/` character.\n   */\n  Slash = 47,\n\n  Digit0 = 48,\n  Digit1 = 49,\n  Digit2 = 50,\n  Digit3 = 51,\n  Digit4 = 52,\n  Digit5 = 53,\n  Digit6 = 54,\n  Digit7 = 55,\n  Digit8 = 56,\n  Digit9 = 57,\n\n  /**\n   * The `:` character.\n   */\n  Colon = 58,\n  /**\n   * The `;` character.\n   */\n  Semicolon = 59,\n  /**\n   * The `<` character.\n   */\n  LessThan = 60,\n  /**\n   * The `=` character.\n   */\n  Equals = 61,\n  /**\n   * The `>` character.\n   */\n  GreaterThan = 62,\n  /**\n   * The `?` character.\n   */\n  QuestionMark = 63,\n  /**\n   * The `@` character.\n   */\n  AtSign = 64,\n\n  A = 65,\n  B = 66,\n  C = 67,\n  D = 68,\n  E = 69,\n  F = 70,\n  G = 71,\n  H = 72,\n  I = 73,\n  J = 74,\n  K = 75,\n  L = 76,\n  M = 77,\n  N = 78,\n  O = 79,\n  P = 80,\n  Q = 81,\n  R = 82,\n  S = 83,\n  T = 84,\n  U = 85,\n  V = 86,\n  W = 87,\n  X = 88,\n  Y = 89,\n  Z = 90,\n\n  /**\n   * The `[` character.\n   */\n  OpenSquareBracket = 91,\n  /**\n   * The `\\` character.\n   */\n  Backslash = 92,\n  /**\n   * The `]` character.\n   */\n  CloseSquareBracket = 93,\n  /**\n   * The `^` character.\n   */\n  Caret = 94,\n  /**\n   * The `_` character.\n   */\n  Underline = 95,\n  /**\n   * The ``(`)`` character.\n   */\n  BackTick = 96,\n\n  a = 97,\n  b = 98,\n  c = 99,\n  d = 100,\n  e = 101,\n  f = 102,\n  g = 103,\n  h = 104,\n  i = 105,\n  j = 106,\n  k = 107,\n  l = 108,\n  m = 109,\n  n = 110,\n  o = 111,\n  p = 112,\n  q = 113,\n  r = 114,\n  s = 115,\n  t = 116,\n  u = 117,\n  v = 118,\n  w = 119,\n  x = 120,\n  y = 121,\n  z = 122,\n\n  /**\n   * The `{` character.\n   */\n  OpenCurlyBrace = 123,\n  /**\n   * The `|` character.\n   */\n  Pipe = 124,\n  /**\n   * The `}` character.\n   */\n  CloseCurlyBrace = 125,\n  /**\n   * The `~` character.\n   */\n  Tilde = 126,\n\n  U_Combining_Grave_Accent = 0x0300, //\tU+0300\tCombining Grave Accent\n  U_Combining_Acute_Accent = 0x0301, //\tU+0301\tCombining Acute Accent\n  U_Combining_Circumflex_Accent = 0x0302, //\tU+0302\tCombining Circumflex Accent\n  U_Combining_Tilde = 0x0303, //\tU+0303\tCombining Tilde\n  U_Combining_Macron = 0x0304, //\tU+0304\tCombining Macron\n  U_Combining_Overline = 0x0305, //\tU+0305\tCombining Overline\n  U_Combining_Breve = 0x0306, //\tU+0306\tCombining Breve\n  U_Combining_Dot_Above = 0x0307, //\tU+0307\tCombining Dot Above\n  U_Combining_Diaeresis = 0x0308, //\tU+0308\tCombining Diaeresis\n  U_Combining_Hook_Above = 0x0309, //\tU+0309\tCombining Hook Above\n  U_Combining_Ring_Above = 0x030a, //\tU+030A\tCombining Ring Above\n  U_Combining_Double_Acute_Accent = 0x030b, //\tU+030B\tCombining Double Acute Accent\n  U_Combining_Caron = 0x030c, //\tU+030C\tCombining Caron\n  U_Combining_Vertical_Line_Above = 0x030d, //\tU+030D\tCombining Vertical Line Above\n  U_Combining_Double_Vertical_Line_Above = 0x030e, //\tU+030E\tCombining Double Vertical Line Above\n  U_Combining_Double_Grave_Accent = 0x030f, //\tU+030F\tCombining Double Grave Accent\n  U_Combining_Candrabindu = 0x0310, //\tU+0310\tCombining Candrabindu\n  U_Combining_Inverted_Breve = 0x0311, //\tU+0311\tCombining Inverted Breve\n  U_Combining_Turned_Comma_Above = 0x0312, //\tU+0312\tCombining Turned Comma Above\n  U_Combining_Comma_Above = 0x0313, //\tU+0313\tCombining Comma Above\n  U_Combining_Reversed_Comma_Above = 0x0314, //\tU+0314\tCombining Reversed Comma Above\n  U_Combining_Comma_Above_Right = 0x0315, //\tU+0315\tCombining Comma Above Right\n  U_Combining_Grave_Accent_Below = 0x0316, //\tU+0316\tCombining Grave Accent Below\n  U_Combining_Acute_Accent_Below = 0x0317, //\tU+0317\tCombining Acute Accent Below\n  U_Combining_Left_Tack_Below = 0x0318, //\tU+0318\tCombining Left Tack Below\n  U_Combining_Right_Tack_Below = 0x0319, //\tU+0319\tCombining Right Tack Below\n  U_Combining_Left_Angle_Above = 0x031a, //\tU+031A\tCombining Left Angle Above\n  U_Combining_Horn = 0x031b, //\tU+031B\tCombining Horn\n  U_Combining_Left_Half_Ring_Below = 0x031c, //\tU+031C\tCombining Left Half Ring Below\n  U_Combining_Up_Tack_Below = 0x031d, //\tU+031D\tCombining Up Tack Below\n  U_Combining_Down_Tack_Below = 0x031e, //\tU+031E\tCombining Down Tack Below\n  U_Combining_Plus_Sign_Below = 0x031f, //\tU+031F\tCombining Plus Sign Below\n  U_Combining_Minus_Sign_Below = 0x0320, //\tU+0320\tCombining Minus Sign Below\n  U_Combining_Palatalized_Hook_Below = 0x0321, //\tU+0321\tCombining Palatalized Hook Below\n  U_Combining_Retroflex_Hook_Below = 0x0322, //\tU+0322\tCombining Retroflex Hook Below\n  U_Combining_Dot_Below = 0x0323, //\tU+0323\tCombining Dot Below\n  U_Combining_Diaeresis_Below = 0x0324, //\tU+0324\tCombining Diaeresis Below\n  U_Combining_Ring_Below = 0x0325, //\tU+0325\tCombining Ring Below\n  U_Combining_Comma_Below = 0x0326, //\tU+0326\tCombining Comma Below\n  U_Combining_Cedilla = 0x0327, //\tU+0327\tCombining Cedilla\n  U_Combining_Ogonek = 0x0328, //\tU+0328\tCombining Ogonek\n  U_Combining_Vertical_Line_Below = 0x0329, //\tU+0329\tCombining Vertical Line Below\n  U_Combining_Bridge_Below = 0x032a, //\tU+032A\tCombining Bridge Below\n  U_Combining_Inverted_Double_Arch_Below = 0x032b, //\tU+032B\tCombining Inverted Double Arch Below\n  U_Combining_Caron_Below = 0x032c, //\tU+032C\tCombining Caron Below\n  U_Combining_Circumflex_Accent_Below = 0x032d, //\tU+032D\tCombining Circumflex Accent Below\n  U_Combining_Breve_Below = 0x032e, //\tU+032E\tCombining Breve Below\n  U_Combining_Inverted_Breve_Below = 0x032f, //\tU+032F\tCombining Inverted Breve Below\n  U_Combining_Tilde_Below = 0x0330, //\tU+0330\tCombining Tilde Below\n  U_Combining_Macron_Below = 0x0331, //\tU+0331\tCombining Macron Below\n  U_Combining_Low_Line = 0x0332, //\tU+0332\tCombining Low Line\n  U_Combining_Double_Low_Line = 0x0333, //\tU+0333\tCombining Double Low Line\n  U_Combining_Tilde_Overlay = 0x0334, //\tU+0334\tCombining Tilde Overlay\n  U_Combining_Short_Stroke_Overlay = 0x0335, //\tU+0335\tCombining Short Stroke Overlay\n  U_Combining_Long_Stroke_Overlay = 0x0336, //\tU+0336\tCombining Long Stroke Overlay\n  U_Combining_Short_Solidus_Overlay = 0x0337, //\tU+0337\tCombining Short Solidus Overlay\n  U_Combining_Long_Solidus_Overlay = 0x0338, //\tU+0338\tCombining Long Solidus Overlay\n  U_Combining_Right_Half_Ring_Below = 0x0339, //\tU+0339\tCombining Right Half Ring Below\n  U_Combining_Inverted_Bridge_Below = 0x033a, //\tU+033A\tCombining Inverted Bridge Below\n  U_Combining_Square_Below = 0x033b, //\tU+033B\tCombining Square Below\n  U_Combining_Seagull_Below = 0x033c, //\tU+033C\tCombining Seagull Below\n  U_Combining_X_Above = 0x033d, //\tU+033D\tCombining X Above\n  U_Combining_Vertical_Tilde = 0x033e, //\tU+033E\tCombining Vertical Tilde\n  U_Combining_Double_Overline = 0x033f, //\tU+033F\tCombining Double Overline\n  U_Combining_Grave_Tone_Mark = 0x0340, //\tU+0340\tCombining Grave Tone Mark\n  U_Combining_Acute_Tone_Mark = 0x0341, //\tU+0341\tCombining Acute Tone Mark\n  U_Combining_Greek_Perispomeni = 0x0342, //\tU+0342\tCombining Greek Perispomeni\n  U_Combining_Greek_Koronis = 0x0343, //\tU+0343\tCombining Greek Koronis\n  U_Combining_Greek_Dialytika_Tonos = 0x0344, //\tU+0344\tCombining Greek Dialytika Tonos\n  U_Combining_Greek_Ypogegrammeni = 0x0345, //\tU+0345\tCombining Greek Ypogegrammeni\n  U_Combining_Bridge_Above = 0x0346, //\tU+0346\tCombining Bridge Above\n  U_Combining_Equals_Sign_Below = 0x0347, //\tU+0347\tCombining Equals Sign Below\n  U_Combining_Double_Vertical_Line_Below = 0x0348, //\tU+0348\tCombining Double Vertical Line Below\n  U_Combining_Left_Angle_Below = 0x0349, //\tU+0349\tCombining Left Angle Below\n  U_Combining_Not_Tilde_Above = 0x034a, //\tU+034A\tCombining Not Tilde Above\n  U_Combining_Homothetic_Above = 0x034b, //\tU+034B\tCombining Homothetic Above\n  U_Combining_Almost_Equal_To_Above = 0x034c, //\tU+034C\tCombining Almost Equal To Above\n  U_Combining_Left_Right_Arrow_Below = 0x034d, //\tU+034D\tCombining Left Right Arrow Below\n  U_Combining_Upwards_Arrow_Below = 0x034e, //\tU+034E\tCombining Upwards Arrow Below\n  U_Combining_Grapheme_Joiner = 0x034f, //\tU+034F\tCombining Grapheme Joiner\n  U_Combining_Right_Arrowhead_Above = 0x0350, //\tU+0350\tCombining Right Arrowhead Above\n  U_Combining_Left_Half_Ring_Above = 0x0351, //\tU+0351\tCombining Left Half Ring Above\n  U_Combining_Fermata = 0x0352, //\tU+0352\tCombining Fermata\n  U_Combining_X_Below = 0x0353, //\tU+0353\tCombining X Below\n  U_Combining_Left_Arrowhead_Below = 0x0354, //\tU+0354\tCombining Left Arrowhead Below\n  U_Combining_Right_Arrowhead_Below = 0x0355, //\tU+0355\tCombining Right Arrowhead Below\n  U_Combining_Right_Arrowhead_And_Up_Arrowhead_Below = 0x0356, //\tU+0356\tCombining Right Arrowhead And Up Arrowhead Below\n  U_Combining_Right_Half_Ring_Above = 0x0357, //\tU+0357\tCombining Right Half Ring Above\n  U_Combining_Dot_Above_Right = 0x0358, //\tU+0358\tCombining Dot Above Right\n  U_Combining_Asterisk_Below = 0x0359, //\tU+0359\tCombining Asterisk Below\n  U_Combining_Double_Ring_Below = 0x035a, //\tU+035A\tCombining Double Ring Below\n  U_Combining_Zigzag_Above = 0x035b, //\tU+035B\tCombining Zigzag Above\n  U_Combining_Double_Breve_Below = 0x035c, //\tU+035C\tCombining Double Breve Below\n  U_Combining_Double_Breve = 0x035d, //\tU+035D\tCombining Double Breve\n  U_Combining_Double_Macron = 0x035e, //\tU+035E\tCombining Double Macron\n  U_Combining_Double_Macron_Below = 0x035f, //\tU+035F\tCombining Double Macron Below\n  U_Combining_Double_Tilde = 0x0360, //\tU+0360\tCombining Double Tilde\n  U_Combining_Double_Inverted_Breve = 0x0361, //\tU+0361\tCombining Double Inverted Breve\n  U_Combining_Double_Rightwards_Arrow_Below = 0x0362, //\tU+0362\tCombining Double Rightwards Arrow Below\n  U_Combining_Latin_Small_Letter_A = 0x0363, //\tU+0363\tCombining Latin Small Letter A\n  U_Combining_Latin_Small_Letter_E = 0x0364, //\tU+0364\tCombining Latin Small Letter E\n  U_Combining_Latin_Small_Letter_I = 0x0365, //\tU+0365\tCombining Latin Small Letter I\n  U_Combining_Latin_Small_Letter_O = 0x0366, //\tU+0366\tCombining Latin Small Letter O\n  U_Combining_Latin_Small_Letter_U = 0x0367, //\tU+0367\tCombining Latin Small Letter U\n  U_Combining_Latin_Small_Letter_C = 0x0368, //\tU+0368\tCombining Latin Small Letter C\n  U_Combining_Latin_Small_Letter_D = 0x0369, //\tU+0369\tCombining Latin Small Letter D\n  U_Combining_Latin_Small_Letter_H = 0x036a, //\tU+036A\tCombining Latin Small Letter H\n  U_Combining_Latin_Small_Letter_M = 0x036b, //\tU+036B\tCombining Latin Small Letter M\n  U_Combining_Latin_Small_Letter_R = 0x036c, //\tU+036C\tCombining Latin Small Letter R\n  U_Combining_Latin_Small_Letter_T = 0x036d, //\tU+036D\tCombining Latin Small Letter T\n  U_Combining_Latin_Small_Letter_V = 0x036e, //\tU+036E\tCombining Latin Small Letter V\n  U_Combining_Latin_Small_Letter_X = 0x036f, //\tU+036F\tCombining Latin Small Letter X\n\n  /**\n   * Unicode Character 'LINE SEPARATOR' (U+2028)\n   * http://www.fileformat.info/info/unicode/char/2028/index.htm\n   */\n  LINE_SEPARATOR = 0x2028,\n  /**\n   * Unicode Character 'PARAGRAPH SEPARATOR' (U+2029)\n   * http://www.fileformat.info/info/unicode/char/2029/index.htm\n   */\n  PARAGRAPH_SEPARATOR = 0x2029,\n  /**\n   * Unicode Character 'NEXT LINE' (U+0085)\n   * http://www.fileformat.info/info/unicode/char/0085/index.htm\n   */\n  NEXT_LINE = 0x0085,\n\n  // http://www.fileformat.info/info/unicode/category/Sk/list.htm\n  U_CIRCUMFLEX = 0x005e, // U+005E\tCIRCUMFLEX\n  U_GRAVE_ACCENT = 0x0060, // U+0060\tGRAVE ACCENT\n  U_DIAERESIS = 0x00a8, // U+00A8\tDIAERESIS\n  U_MACRON = 0x00af, // U+00AF\tMACRON\n  U_ACUTE_ACCENT = 0x00b4, // U+00B4\tACUTE ACCENT\n  U_CEDILLA = 0x00b8, // U+00B8\tCEDILLA\n  U_MODIFIER_LETTER_LEFT_ARROWHEAD = 0x02c2, // U+02C2\tMODIFIER LETTER LEFT ARROWHEAD\n  U_MODIFIER_LETTER_RIGHT_ARROWHEAD = 0x02c3, // U+02C3\tMODIFIER LETTER RIGHT ARROWHEAD\n  U_MODIFIER_LETTER_UP_ARROWHEAD = 0x02c4, // U+02C4\tMODIFIER LETTER UP ARROWHEAD\n  U_MODIFIER_LETTER_DOWN_ARROWHEAD = 0x02c5, // U+02C5\tMODIFIER LETTER DOWN ARROWHEAD\n  U_MODIFIER_LETTER_CENTRED_RIGHT_HALF_RING = 0x02d2, // U+02D2\tMODIFIER LETTER CENTRED RIGHT HALF RING\n  U_MODIFIER_LETTER_CENTRED_LEFT_HALF_RING = 0x02d3, // U+02D3\tMODIFIER LETTER CENTRED LEFT HALF RING\n  U_MODIFIER_LETTER_UP_TACK = 0x02d4, // U+02D4\tMODIFIER LETTER UP TACK\n  U_MODIFIER_LETTER_DOWN_TACK = 0x02d5, // U+02D5\tMODIFIER LETTER DOWN TACK\n  U_MODIFIER_LETTER_PLUS_SIGN = 0x02d6, // U+02D6\tMODIFIER LETTER PLUS SIGN\n  U_MODIFIER_LETTER_MINUS_SIGN = 0x02d7, // U+02D7\tMODIFIER LETTER MINUS SIGN\n  U_BREVE = 0x02d8, // U+02D8\tBREVE\n  U_DOT_ABOVE = 0x02d9, // U+02D9\tDOT ABOVE\n  U_RING_ABOVE = 0x02da, // U+02DA\tRING ABOVE\n  U_OGONEK = 0x02db, // U+02DB\tOGONEK\n  U_SMALL_TILDE = 0x02dc, // U+02DC\tSMALL TILDE\n  U_DOUBLE_ACUTE_ACCENT = 0x02dd, // U+02DD\tDOUBLE ACUTE ACCENT\n  U_MODIFIER_LETTER_RHOTIC_HOOK = 0x02de, // U+02DE\tMODIFIER LETTER RHOTIC HOOK\n  U_MODIFIER_LETTER_CROSS_ACCENT = 0x02df, // U+02DF\tMODIFIER LETTER CROSS ACCENT\n  U_MODIFIER_LETTER_EXTRA_HIGH_TONE_BAR = 0x02e5, // U+02E5\tMODIFIER LETTER EXTRA-HIGH TONE BAR\n  U_MODIFIER_LETTER_HIGH_TONE_BAR = 0x02e6, // U+02E6\tMODIFIER LETTER HIGH TONE BAR\n  U_MODIFIER_LETTER_MID_TONE_BAR = 0x02e7, // U+02E7\tMODIFIER LETTER MID TONE BAR\n  U_MODIFIER_LETTER_LOW_TONE_BAR = 0x02e8, // U+02E8\tMODIFIER LETTER LOW TONE BAR\n  U_MODIFIER_LETTER_EXTRA_LOW_TONE_BAR = 0x02e9, // U+02E9\tMODIFIER LETTER EXTRA-LOW TONE BAR\n  U_MODIFIER_LETTER_YIN_DEPARTING_TONE_MARK = 0x02ea, // U+02EA\tMODIFIER LETTER YIN DEPARTING TONE MARK\n  U_MODIFIER_LETTER_YANG_DEPARTING_TONE_MARK = 0x02eb, // U+02EB\tMODIFIER LETTER YANG DEPARTING TONE MARK\n  U_MODIFIER_LETTER_UNASPIRATED = 0x02ed, // U+02ED\tMODIFIER LETTER UNASPIRATED\n  U_MODIFIER_LETTER_LOW_DOWN_ARROWHEAD = 0x02ef, // U+02EF\tMODIFIER LETTER LOW DOWN ARROWHEAD\n  U_MODIFIER_LETTER_LOW_UP_ARROWHEAD = 0x02f0, // U+02F0\tMODIFIER LETTER LOW UP ARROWHEAD\n  U_MODIFIER_LETTER_LOW_LEFT_ARROWHEAD = 0x02f1, // U+02F1\tMODIFIER LETTER LOW LEFT ARROWHEAD\n  U_MODIFIER_LETTER_LOW_RIGHT_ARROWHEAD = 0x02f2, // U+02F2\tMODIFIER LETTER LOW RIGHT ARROWHEAD\n  U_MODIFIER_LETTER_LOW_RING = 0x02f3, // U+02F3\tMODIFIER LETTER LOW RING\n  U_MODIFIER_LETTER_MIDDLE_GRAVE_ACCENT = 0x02f4, // U+02F4\tMODIFIER LETTER MIDDLE GRAVE ACCENT\n  U_MODIFIER_LETTER_MIDDLE_DOUBLE_GRAVE_ACCENT = 0x02f5, // U+02F5\tMODIFIER LETTER MIDDLE DOUBLE GRAVE ACCENT\n  U_MODIFIER_LETTER_MIDDLE_DOUBLE_ACUTE_ACCENT = 0x02f6, // U+02F6\tMODIFIER LETTER MIDDLE DOUBLE ACUTE ACCENT\n  U_MODIFIER_LETTER_LOW_TILDE = 0x02f7, // U+02F7\tMODIFIER LETTER LOW TILDE\n  U_MODIFIER_LETTER_RAISED_COLON = 0x02f8, // U+02F8\tMODIFIER LETTER RAISED COLON\n  U_MODIFIER_LETTER_BEGIN_HIGH_TONE = 0x02f9, // U+02F9\tMODIFIER LETTER BEGIN HIGH TONE\n  U_MODIFIER_LETTER_END_HIGH_TONE = 0x02fa, // U+02FA\tMODIFIER LETTER END HIGH TONE\n  U_MODIFIER_LETTER_BEGIN_LOW_TONE = 0x02fb, // U+02FB\tMODIFIER LETTER BEGIN LOW TONE\n  U_MODIFIER_LETTER_END_LOW_TONE = 0x02fc, // U+02FC\tMODIFIER LETTER END LOW TONE\n  U_MODIFIER_LETTER_SHELF = 0x02fd, // U+02FD\tMODIFIER LETTER SHELF\n  U_MODIFIER_LETTER_OPEN_SHELF = 0x02fe, // U+02FE\tMODIFIER LETTER OPEN SHELF\n  U_MODIFIER_LETTER_LOW_LEFT_ARROW = 0x02ff, // U+02FF\tMODIFIER LETTER LOW LEFT ARROW\n  U_GREEK_LOWER_NUMERAL_SIGN = 0x0375, // U+0375\tGREEK LOWER NUMERAL SIGN\n  U_GREEK_TONOS = 0x0384, // U+0384\tGREEK TONOS\n  U_GREEK_DIALYTIKA_TONOS = 0x0385, // U+0385\tGREEK DIALYTIKA TONOS\n  U_GREEK_KORONIS = 0x1fbd, // U+1FBD\tGREEK KORONIS\n  U_GREEK_PSILI = 0x1fbf, // U+1FBF\tGREEK PSILI\n  U_GREEK_PERISPOMENI = 0x1fc0, // U+1FC0\tGREEK PERISPOMENI\n  U_GREEK_DIALYTIKA_AND_PERISPOMENI = 0x1fc1, // U+1FC1\tGREEK DIALYTIKA AND PERISPOMENI\n  U_GREEK_PSILI_AND_VARIA = 0x1fcd, // U+1FCD\tGREEK PSILI AND VARIA\n  U_GREEK_PSILI_AND_OXIA = 0x1fce, // U+1FCE\tGREEK PSILI AND OXIA\n  U_GREEK_PSILI_AND_PERISPOMENI = 0x1fcf, // U+1FCF\tGREEK PSILI AND PERISPOMENI\n  U_GREEK_DASIA_AND_VARIA = 0x1fdd, // U+1FDD\tGREEK DASIA AND VARIA\n  U_GREEK_DASIA_AND_OXIA = 0x1fde, // U+1FDE\tGREEK DASIA AND OXIA\n  U_GREEK_DASIA_AND_PERISPOMENI = 0x1fdf, // U+1FDF\tGREEK DASIA AND PERISPOMENI\n  U_GREEK_DIALYTIKA_AND_VARIA = 0x1fed, // U+1FED\tGREEK DIALYTIKA AND VARIA\n  U_GREEK_DIALYTIKA_AND_OXIA = 0x1fee, // U+1FEE\tGREEK DIALYTIKA AND OXIA\n  U_GREEK_VARIA = 0x1fef, // U+1FEF\tGREEK VARIA\n  U_GREEK_OXIA = 0x1ffd, // U+1FFD\tGREEK OXIA\n  U_GREEK_DASIA = 0x1ffe, // U+1FFE\tGREEK DASIA\n\n  U_OVERLINE = 0x203e, // Unicode Character 'OVERLINE'\n\n  /**\n   * UTF-8 BOM\n   * Unicode Character 'ZERO WIDTH NO-BREAK SPACE' (U+FEFF)\n   * http://www.fileformat.info/info/unicode/char/feff/index.htm\n   */\n  UTF8_BOM = 65279,\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/errors.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nexport interface ErrorListenerCallback {\n  (error: any): void;\n}\n\nexport interface ErrorListenerUnbind {\n  (): void;\n}\n\n// Avoid circular dependency on EventEmitter by implementing a subset of the interface.\nexport class ErrorHandler {\n  private unexpectedErrorHandler: (e: any) => void;\n  private listeners: ErrorListenerCallback[];\n\n  constructor() {\n    this.listeners = [];\n\n    this.unexpectedErrorHandler = function (e: any) {\n      setTimeout(() => {\n        if (e.stack) {\n          throw new Error(e.message + '\\n\\n' + e.stack);\n        }\n\n        throw e;\n      }, 0);\n    };\n  }\n\n  addListener(listener: ErrorListenerCallback): ErrorListenerUnbind {\n    this.listeners.push(listener);\n\n    return () => {\n      this._removeListener(listener);\n    };\n  }\n\n  private emit(e: any): void {\n    this.listeners.forEach(listener => {\n      listener(e);\n    });\n  }\n\n  private _removeListener(listener: ErrorListenerCallback): void {\n    this.listeners.splice(this.listeners.indexOf(listener), 1);\n  }\n\n  setUnexpectedErrorHandler(newUnexpectedErrorHandler: (e: any) => void): void {\n    this.unexpectedErrorHandler = newUnexpectedErrorHandler;\n  }\n\n  getUnexpectedErrorHandler(): (e: any) => void {\n    return this.unexpectedErrorHandler;\n  }\n\n  onUnexpectedError(e: any): void {\n    this.unexpectedErrorHandler(e);\n    this.emit(e);\n  }\n\n  // For external errors, we don't want the listeners to be called\n  onUnexpectedExternalError(e: any): void {\n    this.unexpectedErrorHandler(e);\n  }\n}\n\nexport const errorHandler = new ErrorHandler();\n\nexport function setUnexpectedErrorHandler(\n  newUnexpectedErrorHandler: (e: any) => void\n): void {\n  errorHandler.setUnexpectedErrorHandler(newUnexpectedErrorHandler);\n}\n\nexport function onUnexpectedError(e: any): undefined {\n  // ignore errors from cancelled promises\n  if (!isPromiseCanceledError(e)) {\n    errorHandler.onUnexpectedError(e);\n  }\n  return undefined;\n}\n\nexport function onUnexpectedExternalError(e: any): undefined {\n  // ignore errors from cancelled promises\n  if (!isPromiseCanceledError(e)) {\n    errorHandler.onUnexpectedExternalError(e);\n  }\n  return undefined;\n}\n\nexport interface SerializedError {\n  readonly $isError: true;\n  readonly name: string;\n  readonly message: string;\n  readonly stack: string;\n}\n\nexport function transformErrorForSerialization(error: Error): SerializedError;\nexport function transformErrorForSerialization(error: any): any;\nexport function transformErrorForSerialization(error: any): any {\n  if (error instanceof Error) {\n    let { name, message } = error;\n    const stack: string = (error as any).stacktrace || (error as any).stack;\n    return {\n      $isError: true,\n      name,\n      message,\n      stack,\n    };\n  }\n\n  // return as is\n  return error;\n}\n\n// see https://github.com/v8/v8/wiki/Stack%20Trace%20API#basic-stack-traces\nexport interface V8CallSite {\n  getThis(): any;\n  getTypeName(): string;\n  getFunction(): string;\n  getFunctionName(): string;\n  getMethodName(): string;\n  getFileName(): string;\n  getLineNumber(): number;\n  getColumnNumber(): number;\n  getEvalOrigin(): string;\n  isToplevel(): boolean;\n  isEval(): boolean;\n  isNative(): boolean;\n  isConstructor(): boolean;\n  toString(): string;\n}\n\nconst canceledName = 'Canceled';\n\n/**\n * Checks if the given error is a promise in canceled state\n */\nexport function isPromiseCanceledError(error: any): boolean {\n  return (\n    error instanceof Error &&\n    error.name === canceledName &&\n    error.message === canceledName\n  );\n}\n\n/**\n * Returns an error that signals cancellation.\n */\nexport function canceled(): Error {\n  const error = new Error(canceledName);\n  error.name = error.message;\n  return error;\n}\n\nexport function illegalArgument(name?: string): Error {\n  if (name) {\n    return new Error(`Illegal argument: ${name}`);\n  } else {\n    return new Error('Illegal argument');\n  }\n}\n\nexport function illegalState(name?: string): Error {\n  if (name) {\n    return new Error(`Illegal state: ${name}`);\n  } else {\n    return new Error('Illegal state');\n  }\n}\n\nexport function readonly(name?: string): Error {\n  return name\n    ? new Error(`readonly property '${name} cannot be changed'`)\n    : new Error('readonly property cannot be changed');\n}\n\nexport function disposed(what: string): Error {\n  const result = new Error(`${what} has been disposed`);\n  result.name = 'DISPOSED';\n  return result;\n}\n\nexport function getErrorMessage(err: any): string {\n  if (!err) {\n    return 'Error';\n  }\n\n  if (err.message) {\n    return err.message;\n  }\n\n  if (err.stack) {\n    return err.stack.split('\\n')[0];\n  }\n\n  return String(err);\n}\n\nexport class NotImplementedError extends Error {\n  constructor(message?: string) {\n    super('NotImplemented');\n    if (message) {\n      this.message = message;\n    }\n  }\n}\n\nexport class NotSupportedError extends Error {\n  constructor(message?: string) {\n    super('NotSupported');\n    if (message) {\n      this.message = message;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/event.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nimport { onUnexpectedError } from './errors';\nimport { once as onceFn } from './functional';\nimport {\n  Disposable,\n  IDisposable,\n  toDisposable,\n  combinedDisposable,\n  DisposableStore,\n} from './lifecycle';\nimport { LinkedList } from './linkedList';\n\n/**\n * To an event a function with one or zero parameters\n * can be subscribed. The event is the subscriber function itself.\n */\nexport interface Event<T> {\n  (\n    listener: (e: T) => any,\n    thisArgs?: any,\n    disposables?: IDisposable[] | DisposableStore\n  ): IDisposable;\n}\n\nexport namespace Event {\n  export const None: Event<any> = () => Disposable.None;\n\n  /**\n   * Given an event, returns another event which only fires once.\n   */\n  export function once<T>(event: Event<T>): Event<T> {\n    return (listener, thisArgs = null, disposables?) => {\n      // we need this, in case the event fires during the listener call\n      let didFire = false;\n      let result: IDisposable;\n      result = event(\n        e => {\n          if (didFire) {\n            return;\n          } else if (result) {\n            result.dispose();\n          } else {\n            didFire = true;\n          }\n\n          return listener.call(thisArgs, e);\n        },\n        null,\n        disposables\n      );\n\n      if (didFire) {\n        result.dispose();\n      }\n\n      return result;\n    };\n  }\n\n  /**\n   * Given an event and a `map` function, returns another event which maps each element\n   * through the mapping function.\n   */\n  export function map<I, O>(event: Event<I>, map: (i: I) => O): Event<O> {\n    return snapshot((listener, thisArgs = null, disposables?) =>\n      event(i => listener.call(thisArgs, map(i)), null, disposables)\n    );\n  }\n\n  /**\n   * Given an event and an `each` function, returns another identical event and calls\n   * the `each` function per each element.\n   */\n  export function forEach<I>(event: Event<I>, each: (i: I) => void): Event<I> {\n    return snapshot((listener, thisArgs = null, disposables?) =>\n      event(\n        i => {\n          each(i);\n          listener.call(thisArgs, i);\n        },\n        null,\n        disposables\n      )\n    );\n  }\n\n  /**\n   * Given an event and a `filter` function, returns another event which emits those\n   * elements for which the `filter` function returns `true`.\n   */\n  export function filter<T>(\n    event: Event<T>,\n    filter: (e: T) => boolean\n  ): Event<T>;\n  export function filter<T, R>(\n    event: Event<T | R>,\n    filter: (e: T | R) => e is R\n  ): Event<R>;\n  export function filter<T>(\n    event: Event<T>,\n    filter: (e: T) => boolean\n  ): Event<T> {\n    return snapshot((listener, thisArgs = null, disposables?) =>\n      event(e => filter(e) && listener.call(thisArgs, e), null, disposables)\n    );\n  }\n\n  /**\n   * Given an event, returns the same event but typed as `Event<void>`.\n   */\n  export function signal<T>(event: Event<T>): Event<void> {\n    return event as Event<any> as Event<void>;\n  }\n\n  /**\n   * Given a collection of events, returns a single event which emits\n   * whenever any of the provided events emit.\n   */\n  export function any<T>(...events: Event<T>[]): Event<T>;\n  export function any(...events: Event<any>[]): Event<void>;\n  export function any<T>(...events: Event<T>[]): Event<T> {\n    return (listener, thisArgs = null, disposables?) =>\n      combinedDisposable(\n        ...events.map(event =>\n          event(e => listener.call(thisArgs, e), null, disposables)\n        )\n      );\n  }\n\n  /**\n   * Given an event and a `merge` function, returns another event which maps each element\n   * and the cumulative result through the `merge` function. Similar to `map`, but with memory.\n   */\n  export function reduce<I, O>(\n    event: Event<I>,\n    merge: (last: O | undefined, event: I) => O,\n    initial?: O\n  ): Event<O> {\n    let output: O | undefined = initial;\n\n    return map<I, O>(event, e => {\n      output = merge(output, e);\n      return output;\n    });\n  }\n\n  /**\n   * Given a chain of event processing functions (filter, map, etc), each\n   * function will be invoked per event & per listener. Snapshotting an event\n   * chain allows each function to be invoked just once per event.\n   */\n  export function snapshot<T>(event: Event<T>): Event<T> {\n    let listener: IDisposable;\n    const emitter = new Emitter<T>({\n      onFirstListenerAdd() {\n        listener = event(emitter.fire, emitter);\n      },\n      onLastListenerRemove() {\n        listener.dispose();\n      },\n    });\n\n    return emitter.event;\n  }\n\n  /**\n   * Debounces the provided event, given a `merge` function.\n   *\n   * @param event The input event.\n   * @param merge The reducing function.\n   * @param delay The debouncing delay in millis.\n   * @param leading Whether the event should fire in the leading phase of the timeout.\n   * @param leakWarningThreshold The leak warning threshold override.\n   */\n  export function debounce<T>(\n    event: Event<T>,\n    merge: (last: T | undefined, event: T) => T,\n    delay?: number,\n    leading?: boolean,\n    leakWarningThreshold?: number\n  ): Event<T>;\n  export function debounce<I, O>(\n    event: Event<I>,\n    merge: (last: O | undefined, event: I) => O,\n    delay?: number,\n    leading?: boolean,\n    leakWarningThreshold?: number\n  ): Event<O>;\n  export function debounce<I, O>(\n    event: Event<I>,\n    merge: (last: O | undefined, event: I) => O,\n    delay: number = 100,\n    leading = false,\n    leakWarningThreshold?: number\n  ): Event<O> {\n    let subscription: IDisposable;\n    let output: O | undefined = undefined;\n    let handle: any = undefined;\n    let numDebouncedCalls = 0;\n\n    const emitter = new Emitter<O>({\n      leakWarningThreshold,\n      onFirstListenerAdd() {\n        subscription = event(cur => {\n          numDebouncedCalls++;\n          output = merge(output, cur);\n\n          if (leading && !handle) {\n            emitter.fire(output);\n            output = undefined;\n          }\n\n          clearTimeout(handle);\n          handle = setTimeout(() => {\n            const _output = output;\n            output = undefined;\n            handle = undefined;\n            if (!leading || numDebouncedCalls > 1) {\n              emitter.fire(_output!);\n            }\n\n            numDebouncedCalls = 0;\n          }, delay);\n        });\n      },\n      onLastListenerRemove() {\n        subscription.dispose();\n      },\n    });\n\n    return emitter.event;\n  }\n\n  /**\n   * Given an event, it returns another event which fires only once and as soon as\n   * the input event emits. The event data is the number of millis it took for the\n   * event to fire.\n   */\n  export function stopwatch<T>(event: Event<T>): Event<number> {\n    const start = new Date().getTime();\n    return map(once(event), _ => new Date().getTime() - start);\n  }\n\n  /**\n   * Given an event, it returns another event which fires only when the event\n   * element changes.\n   */\n  export function latch<T>(event: Event<T>): Event<T> {\n    let firstCall = true;\n    let cache: T;\n\n    return filter(event, value => {\n      const shouldEmit = firstCall || value !== cache;\n      firstCall = false;\n      cache = value;\n      return shouldEmit;\n    });\n  }\n\n  /**\n   * Buffers the provided event until a first listener comes\n   * along, at which point fire all the events at once and\n   * pipe the event from then on.\n   *\n   * ```typescript\n   * const emitter = new Emitter<number>();\n   * const event = emitter.event;\n   * const bufferedEvent = buffer(event);\n   *\n   * emitter.fire(1);\n   * emitter.fire(2);\n   * emitter.fire(3);\n   * // nothing...\n   *\n   * const listener = bufferedEvent(num => console.log(num));\n   * // 1, 2, 3\n   *\n   * emitter.fire(4);\n   * // 4\n   * ```\n   */\n  export function buffer<T>(\n    event: Event<T>,\n    nextTick = false,\n    _buffer: T[] = []\n  ): Event<T> {\n    let buffer: T[] | null = _buffer.slice();\n\n    let listener: IDisposable | null = event(e => {\n      if (buffer) {\n        buffer.push(e);\n      } else {\n        emitter.fire(e);\n      }\n    });\n\n    const flush = () => {\n      if (buffer) {\n        buffer.forEach(e => emitter.fire(e));\n      }\n      buffer = null;\n    };\n\n    const emitter = new Emitter<T>({\n      onFirstListenerAdd() {\n        if (!listener) {\n          listener = event(e => emitter.fire(e));\n        }\n      },\n\n      onFirstListenerDidAdd() {\n        if (buffer) {\n          if (nextTick) {\n            setTimeout(flush, 0);\n          } else {\n            flush();\n          }\n        }\n      },\n\n      onLastListenerRemove() {\n        if (listener) {\n          listener.dispose();\n        }\n        listener = null;\n      },\n    });\n\n    return emitter.event;\n  }\n\n  export interface IChainableEvent<T> {\n    event: Event<T>;\n    map<O>(fn: (i: T) => O): IChainableEvent<O>;\n    forEach(fn: (i: T) => void): IChainableEvent<T>;\n    filter(fn: (e: T) => boolean): IChainableEvent<T>;\n    filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;\n    reduce<R>(\n      merge: (last: R | undefined, event: T) => R,\n      initial?: R\n    ): IChainableEvent<R>;\n    latch(): IChainableEvent<T>;\n    debounce(\n      merge: (last: T | undefined, event: T) => T,\n      delay?: number,\n      leading?: boolean,\n      leakWarningThreshold?: number\n    ): IChainableEvent<T>;\n    debounce<R>(\n      merge: (last: R | undefined, event: T) => R,\n      delay?: number,\n      leading?: boolean,\n      leakWarningThreshold?: number\n    ): IChainableEvent<R>;\n    on(\n      listener: (e: T) => any,\n      thisArgs?: any,\n      disposables?: IDisposable[] | DisposableStore\n    ): IDisposable;\n    once(\n      listener: (e: T) => any,\n      thisArgs?: any,\n      disposables?: IDisposable[]\n    ): IDisposable;\n  }\n\n  class ChainableEvent<T> implements IChainableEvent<T> {\n    constructor(readonly event: Event<T>) {}\n\n    map<O>(fn: (i: T) => O): IChainableEvent<O> {\n      return new ChainableEvent(map(this.event, fn));\n    }\n\n    forEach(fn: (i: T) => void): IChainableEvent<T> {\n      return new ChainableEvent(forEach(this.event, fn));\n    }\n\n    filter(fn: (e: T) => boolean): IChainableEvent<T>;\n    filter<R>(fn: (e: T | R) => e is R): IChainableEvent<R>;\n    filter(fn: (e: T) => boolean): IChainableEvent<T> {\n      return new ChainableEvent(filter(this.event, fn));\n    }\n\n    reduce<R>(\n      merge: (last: R | undefined, event: T) => R,\n      initial?: R\n    ): IChainableEvent<R> {\n      return new ChainableEvent(reduce(this.event, merge, initial));\n    }\n\n    latch(): IChainableEvent<T> {\n      return new ChainableEvent(latch(this.event));\n    }\n\n    debounce(\n      merge: (last: T | undefined, event: T) => T,\n      delay?: number,\n      leading?: boolean,\n      leakWarningThreshold?: number\n    ): IChainableEvent<T>;\n    debounce<R>(\n      merge: (last: R | undefined, event: T) => R,\n      delay?: number,\n      leading?: boolean,\n      leakWarningThreshold?: number\n    ): IChainableEvent<R>;\n    debounce<R>(\n      merge: (last: R | undefined, event: T) => R,\n      delay: number = 100,\n      leading = false,\n      leakWarningThreshold?: number\n    ): IChainableEvent<R> {\n      return new ChainableEvent(\n        debounce(this.event, merge, delay, leading, leakWarningThreshold)\n      );\n    }\n\n    on(\n      listener: (e: T) => any,\n      thisArgs: any,\n      disposables: IDisposable[] | DisposableStore\n    ) {\n      return this.event(listener, thisArgs, disposables);\n    }\n\n    once(listener: (e: T) => any, thisArgs: any, disposables: IDisposable[]) {\n      return once(this.event)(listener, thisArgs, disposables);\n    }\n  }\n\n  export function chain<T>(event: Event<T>): IChainableEvent<T> {\n    return new ChainableEvent(event);\n  }\n\n  export interface NodeEventEmitter {\n    on(event: string | symbol, listener: Function): unknown;\n    removeListener(event: string | symbol, listener: Function): unknown;\n  }\n\n  export function fromNodeEventEmitter<T>(\n    emitter: NodeEventEmitter,\n    eventName: string,\n    map: (...args: any[]) => T = id => id\n  ): Event<T> {\n    const fn = (...args: any[]) => result.fire(map(...args));\n    const onFirstListenerAdd = () => emitter.on(eventName, fn);\n    const onLastListenerRemove = () => emitter.removeListener(eventName, fn);\n    const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });\n\n    return result.event;\n  }\n\n  export interface DOMEventEmitter {\n    addEventListener(event: string | symbol, listener: Function): void;\n    removeEventListener(event: string | symbol, listener: Function): void;\n  }\n\n  export function fromDOMEventEmitter<T>(\n    emitter: DOMEventEmitter,\n    eventName: string,\n    map: (...args: any[]) => T = id => id\n  ): Event<T> {\n    const fn = (...args: any[]) => result.fire(map(...args));\n    const onFirstListenerAdd = () => emitter.addEventListener(eventName, fn);\n    const onLastListenerRemove = () =>\n      emitter.removeEventListener(eventName, fn);\n    const result = new Emitter<T>({ onFirstListenerAdd, onLastListenerRemove });\n\n    return result.event;\n  }\n\n  export function fromPromise<T = any>(promise: Promise<T>): Event<undefined> {\n    const emitter = new Emitter<undefined>();\n    let shouldEmit = false;\n\n    promise\n      .then(undefined, () => null)\n      .then(() => {\n        if (!shouldEmit) {\n          setTimeout(() => emitter.fire(undefined), 0);\n        } else {\n          emitter.fire(undefined);\n        }\n      });\n\n    shouldEmit = true;\n    return emitter.event;\n  }\n\n  export function toPromise<T>(event: Event<T>): Promise<T> {\n    return new Promise(c => once(event)(c));\n  }\n}\n\ntype Listener<T> = [(e: T) => void, any] | ((e: T) => void);\n\nexport interface EmitterOptions {\n  onFirstListenerAdd?: Function;\n  onFirstListenerDidAdd?: Function;\n  onListenerDidAdd?: Function;\n  onLastListenerRemove?: Function;\n  leakWarningThreshold?: number;\n}\n\nlet _globalLeakWarningThreshold = -1;\nexport function setGlobalLeakWarningThreshold(n: number): IDisposable {\n  const oldValue = _globalLeakWarningThreshold;\n  _globalLeakWarningThreshold = n;\n  return {\n    dispose() {\n      _globalLeakWarningThreshold = oldValue;\n    },\n  };\n}\n\nclass LeakageMonitor {\n  private _stacks: Map<string, number> | undefined;\n  private _warnCountdown: number = 0;\n\n  constructor(\n    readonly customThreshold?: number,\n    readonly name: string = Math.random().toString(18).slice(2, 5)\n  ) {}\n\n  dispose(): void {\n    if (this._stacks) {\n      this._stacks.clear();\n    }\n  }\n\n  check(listenerCount: number): undefined | (() => void) {\n    let threshold = _globalLeakWarningThreshold;\n    if (typeof this.customThreshold === 'number') {\n      threshold = this.customThreshold;\n    }\n\n    if (threshold <= 0 || listenerCount < threshold) {\n      return undefined;\n    }\n\n    if (!this._stacks) {\n      this._stacks = new Map();\n    }\n    const stack = new Error().stack!.split('\\n').slice(3).join('\\n');\n    const count = this._stacks.get(stack) || 0;\n    this._stacks.set(stack, count + 1);\n    this._warnCountdown -= 1;\n\n    if (this._warnCountdown <= 0) {\n      // only warn on first exceed and then every time the limit\n      // is exceeded by 50% again\n      this._warnCountdown = threshold * 0.5;\n\n      // find most frequent listener and print warning\n      let topStack: string | undefined;\n      let topCount: number = 0;\n      for (const [stack, count] of this._stacks) {\n        if (!topStack || topCount < count) {\n          topStack = stack;\n          topCount = count;\n        }\n      }\n\n      console.warn(\n        `[${this.name}] potential listener LEAK detected, having ${listenerCount} listeners already. MOST frequent listener (${topCount}):`\n      );\n      console.warn(topStack!);\n    }\n\n    return () => {\n      const count = this._stacks!.get(stack) || 0;\n      this._stacks!.set(stack, count - 1);\n    };\n  }\n}\n\n/**\n * The Emitter can be used to expose an Event to the public\n * to fire it from the insides.\n * Sample:\n\tclass Document {\n\n\t\tprivate readonly _onDidChange = new Emitter<(value:string)=>any>();\n\n\t\tpublic onDidChange = this._onDidChange.event;\n\n\t\t// getter-style\n\t\t// get onDidChange(): Event<(value:string)=>any> {\n\t\t// \treturn this._onDidChange.event;\n\t\t// }\n\n\t\tprivate _doIt() {\n\t\t\t//...\n\t\t\tthis._onDidChange.fire(value);\n\t\t}\n\t}\n */\nexport class Emitter<T> {\n  private static readonly _noop = function () {};\n\n  private readonly _options?: EmitterOptions;\n  private readonly _leakageMon?: LeakageMonitor;\n  private _disposed: boolean = false;\n  private _event?: Event<T>;\n  private _deliveryQueue?: LinkedList<[Listener<T>, T]>;\n  protected _listeners?: LinkedList<Listener<T>>;\n\n  constructor(options?: EmitterOptions) {\n    this._options = options;\n    this._leakageMon =\n      _globalLeakWarningThreshold > 0\n        ? new LeakageMonitor(\n            this._options && this._options.leakWarningThreshold\n          )\n        : undefined;\n  }\n\n  /**\n   * For the public to allow to subscribe\n   * to events from this Emitter\n   */\n  get event(): Event<T> {\n    if (!this._event) {\n      this._event = (\n        listener: (e: T) => any,\n        thisArgs?: any,\n        disposables?: IDisposable[] | DisposableStore\n      ) => {\n        if (!this._listeners) {\n          this._listeners = new LinkedList();\n        }\n\n        const firstListener = this._listeners.isEmpty();\n\n        if (\n          firstListener &&\n          this._options &&\n          this._options.onFirstListenerAdd\n        ) {\n          this._options.onFirstListenerAdd(this);\n        }\n\n        const remove = this._listeners.push(\n          !thisArgs ? listener : [listener, thisArgs]\n        );\n\n        if (\n          firstListener &&\n          this._options &&\n          this._options.onFirstListenerDidAdd\n        ) {\n          this._options.onFirstListenerDidAdd(this);\n        }\n\n        if (this._options && this._options.onListenerDidAdd) {\n          this._options.onListenerDidAdd(this, listener, thisArgs);\n        }\n\n        // check and record this emitter for potential leakage\n        let removeMonitor: (() => void) | undefined;\n        if (this._leakageMon) {\n          removeMonitor = this._leakageMon.check(this._listeners.size);\n        }\n\n        let result: IDisposable;\n        result = {\n          dispose: () => {\n            if (removeMonitor) {\n              removeMonitor();\n            }\n            result.dispose = Emitter._noop;\n            if (!this._disposed) {\n              remove();\n              if (this._options && this._options.onLastListenerRemove) {\n                const hasListeners =\n                  this._listeners && !this._listeners.isEmpty();\n                if (!hasListeners) {\n                  this._options.onLastListenerRemove(this);\n                }\n              }\n            }\n          },\n        };\n        if (disposables instanceof DisposableStore) {\n          disposables.add(result);\n        } else if (Array.isArray(disposables)) {\n          disposables.push(result);\n        }\n\n        return result;\n      };\n    }\n    return this._event;\n  }\n\n  /**\n   * To be kept private to fire an event to\n   * subscribers\n   */\n  fire(event: T): void {\n    if (this._listeners) {\n      // put all [listener,event]-pairs into delivery queue\n      // then emit all event. an inner/nested event might be\n      // the driver of this\n\n      if (!this._deliveryQueue) {\n        this._deliveryQueue = new LinkedList();\n      }\n\n      for (let listener of this._listeners) {\n        this._deliveryQueue.push([listener, event]);\n      }\n\n      while (this._deliveryQueue.size > 0) {\n        const [listener, event] = this._deliveryQueue.shift()!;\n        try {\n          if (typeof listener === 'function') {\n            listener.call(undefined, event);\n          } else {\n            listener[0].call(listener[1], event);\n          }\n        } catch (e) {\n          onUnexpectedError(e);\n        }\n      }\n    }\n  }\n\n  dispose() {\n    if (this._listeners) {\n      this._listeners.clear();\n    }\n    if (this._deliveryQueue) {\n      this._deliveryQueue.clear();\n    }\n    if (this._leakageMon) {\n      this._leakageMon.dispose();\n    }\n    this._disposed = true;\n  }\n}\n\nexport class PauseableEmitter<T> extends Emitter<T> {\n  private _isPaused = 0;\n  private _eventQueue = new LinkedList<T>();\n  private _mergeFn?: (input: T[]) => T;\n\n  constructor(options?: EmitterOptions & { merge?: (input: T[]) => T }) {\n    super(options);\n    this._mergeFn = options && options.merge;\n  }\n\n  pause(): void {\n    this._isPaused++;\n  }\n\n  resume(): void {\n    if (this._isPaused !== 0 && --this._isPaused === 0) {\n      if (this._mergeFn) {\n        // use the merge function to create a single composite\n        // event. make a copy in case firing pauses this emitter\n        const events = Array.from(this._eventQueue);\n        this._eventQueue.clear();\n        super.fire(this._mergeFn(events));\n      } else {\n        // no merging, fire each event individually and test\n        // that this emitter isn't paused halfway through\n        while (!this._isPaused && this._eventQueue.size !== 0) {\n          super.fire(this._eventQueue.shift()!);\n        }\n      }\n    }\n  }\n\n  fire(event: T): void {\n    if (this._listeners) {\n      if (this._isPaused !== 0) {\n        this._eventQueue.push(event);\n      } else {\n        super.fire(event);\n      }\n    }\n  }\n}\n\nexport interface IWaitUntil {\n  waitUntil(thenable: Promise<any>): void;\n}\n\nexport class EventMultiplexer<T> implements IDisposable {\n  private readonly emitter: Emitter<T>;\n  private hasListeners = false;\n  private events: { event: Event<T>; listener: IDisposable | null }[] = [];\n\n  constructor() {\n    this.emitter = new Emitter<T>({\n      onFirstListenerAdd: () => this.onFirstListenerAdd(),\n      onLastListenerRemove: () => this.onLastListenerRemove(),\n    });\n  }\n\n  get event(): Event<T> {\n    return this.emitter.event;\n  }\n\n  add(event: Event<T>): IDisposable {\n    const e = { event: event, listener: null };\n    this.events.push(e);\n\n    if (this.hasListeners) {\n      this.hook(e);\n    }\n\n    const dispose = () => {\n      if (this.hasListeners) {\n        this.unhook(e);\n      }\n\n      const idx = this.events.indexOf(e);\n      this.events.splice(idx, 1);\n    };\n\n    return toDisposable(onceFn(dispose));\n  }\n\n  private onFirstListenerAdd(): void {\n    this.hasListeners = true;\n    this.events.forEach(e => this.hook(e));\n  }\n\n  private onLastListenerRemove(): void {\n    this.hasListeners = false;\n    this.events.forEach(e => this.unhook(e));\n  }\n\n  private hook(e: { event: Event<T>; listener: IDisposable | null }): void {\n    e.listener = e.event(r => this.emitter.fire(r));\n  }\n\n  private unhook(e: { event: Event<T>; listener: IDisposable | null }): void {\n    if (e.listener) {\n      e.listener.dispose();\n    }\n    e.listener = null;\n  }\n\n  dispose(): void {\n    this.emitter.dispose();\n  }\n}\n\n/**\n * The EventBufferer is useful in situations in which you want\n * to delay firing your events during some code.\n * You can wrap that code and be sure that the event will not\n * be fired during that wrap.\n *\n * ```\n * const emitter: Emitter;\n * const delayer = new EventDelayer();\n * const delayedEvent = delayer.wrapEvent(emitter.event);\n *\n * delayedEvent(console.log);\n *\n * delayer.bufferEvents(() => {\n *   emitter.fire(); // event will not be fired yet\n * });\n *\n * // event will only be fired at this point\n * ```\n */\nexport class EventBufferer {\n  private buffers: Function[][] = [];\n\n  wrapEvent<T>(event: Event<T>): Event<T> {\n    return (listener, thisArgs?, disposables?) => {\n      return event(\n        i => {\n          const buffer = this.buffers[this.buffers.length - 1];\n\n          if (buffer) {\n            buffer.push(() => listener.call(thisArgs, i));\n          } else {\n            listener.call(thisArgs, i);\n          }\n        },\n        undefined,\n        disposables\n      );\n    };\n  }\n\n  bufferEvents<R = void>(fn: () => R): R {\n    const buffer: Array<() => R> = [];\n    this.buffers.push(buffer);\n    const r = fn();\n    this.buffers.pop();\n    buffer.forEach(flush => flush());\n    return r;\n  }\n}\n\n/**\n * A Relay is an event forwarder which functions as a replugabble event pipe.\n * Once created, you can connect an input event to it and it will simply forward\n * events from that input event through its own `event` property. The `input`\n * can be changed at any point in time.\n */\nexport class Relay<T> implements IDisposable {\n  private listening = false;\n  private inputEvent: Event<T> = Event.None;\n  private inputEventListener: IDisposable = Disposable.None;\n\n  private readonly emitter = new Emitter<T>({\n    onFirstListenerDidAdd: () => {\n      this.listening = true;\n      this.inputEventListener = this.inputEvent(\n        this.emitter.fire,\n        this.emitter\n      );\n    },\n    onLastListenerRemove: () => {\n      this.listening = false;\n      this.inputEventListener.dispose();\n    },\n  });\n\n  readonly event: Event<T> = this.emitter.event;\n\n  set input(event: Event<T>) {\n    this.inputEvent = event;\n\n    if (this.listening) {\n      this.inputEventListener.dispose();\n      this.inputEventListener = event(this.emitter.fire, this.emitter);\n    }\n  }\n\n  dispose() {\n    this.inputEventListener.dispose();\n    this.emitter.dispose();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/functional.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nexport function once<T extends Function>(this: unknown, fn: T): T {\n  const _this = this;\n  let didCall = false;\n  let result: unknown;\n\n  return function () {\n    if (didCall) {\n      return result;\n    }\n\n    didCall = true;\n    result = fn.apply(_this, arguments);\n\n    return result;\n  } as unknown as T;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/iterator.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nexport namespace Iterable {\n  export function is<T = any>(thing: any): thing is IterableIterator<T> {\n    return (\n      thing &&\n      typeof thing === 'object' &&\n      typeof thing[Symbol.iterator] === 'function'\n    );\n  }\n\n  const _empty: Iterable<any> = Object.freeze([]);\n  export function empty<T = any>(): Iterable<T> {\n    return _empty;\n  }\n\n  export function* single<T>(element: T): Iterable<T> {\n    yield element;\n  }\n\n  export function from<T>(\n    iterable: Iterable<T> | undefined | null\n  ): Iterable<T> {\n    return iterable || _empty;\n  }\n\n  export function first<T>(iterable: Iterable<T>): T | undefined {\n    return iterable[Symbol.iterator]().next().value;\n  }\n\n  export function some<T>(\n    iterable: Iterable<T>,\n    predicate: (t: T) => boolean\n  ): boolean {\n    for (const element of iterable) {\n      if (predicate(element)) {\n        return true;\n      }\n    }\n    return false;\n  }\n\n  export function* filter<T>(\n    iterable: Iterable<T>,\n    predicate: (t: T) => boolean\n  ): Iterable<T> {\n    for (const element of iterable) {\n      if (predicate(element)) {\n        yield element;\n      }\n    }\n  }\n\n  export function* map<T, R>(\n    iterable: Iterable<T>,\n    fn: (t: T) => R\n  ): Iterable<R> {\n    for (const element of iterable) {\n      yield fn(element);\n    }\n  }\n\n  export function* concat<T>(...iterables: Iterable<T>[]): Iterable<T> {\n    for (const iterable of iterables) {\n      for (const element of iterable) {\n        yield element;\n      }\n    }\n  }\n\n  /**\n   * Consumes `atMost` elements from iterable and returns the consumed elements,\n   * and an iterable for the rest of the elements.\n   */\n  export function consume<T>(\n    iterable: Iterable<T>,\n    atMost: number = Number.POSITIVE_INFINITY\n  ): [T[], Iterable<T>] {\n    const consumed: T[] = [];\n\n    if (atMost === 0) {\n      return [consumed, iterable];\n    }\n\n    const iterator = iterable[Symbol.iterator]();\n\n    for (let i = 0; i < atMost; i++) {\n      const next = iterator.next();\n\n      if (next.done) {\n        return [consumed, Iterable.empty()];\n      }\n\n      consumed.push(next.value);\n    }\n\n    return [\n      consumed,\n      {\n        [Symbol.iterator]() {\n          return iterator;\n        },\n      },\n    ];\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/lifecycle.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nimport { once } from './functional';\nimport { Iterable } from './iterator';\n\n/**\n * Enables logging of potentially leaked disposables.\n *\n * A disposable is considered leaked if it is not disposed or not registered as the child of\n * another disposable. This tracking is very simple an only works for classes that either\n * extend Disposable or use a DisposableStore. This means there are a lot of false positives.\n */\nconst TRACK_DISPOSABLES = false;\n\nconst __is_disposable_tracked__ = '__is_disposable_tracked__';\n\nfunction markTracked<T extends IDisposable>(x: T): void {\n  if (!TRACK_DISPOSABLES) {\n    return;\n  }\n\n  if (x && x !== Disposable.None) {\n    try {\n      (x as any)[__is_disposable_tracked__] = true;\n    } catch {\n      // noop\n    }\n  }\n}\n\nfunction trackDisposable<T extends IDisposable>(x: T): T {\n  if (!TRACK_DISPOSABLES) {\n    return x;\n  }\n\n  const stack = new Error('Potentially leaked disposable').stack!;\n  setTimeout(() => {\n    if (!(x as any)[__is_disposable_tracked__]) {\n      console.log(stack);\n    }\n  }, 3000);\n  return x;\n}\n\nexport class MultiDisposeError extends Error {\n  constructor(public readonly errors: any[]) {\n    super(\n      `Encounter errors while disposing of store. Errors: [${errors.join(\n        ', '\n      )}]`\n    );\n  }\n}\n\nexport interface IDisposable {\n  dispose(): void;\n}\n\nexport function isDisposable<E extends object>(\n  thing: E\n): thing is E & IDisposable {\n  return (\n    typeof (thing as IDisposable).dispose === 'function' &&\n    (thing as IDisposable).dispose.length === 0\n  );\n}\n\nexport function dispose<T extends IDisposable>(disposable: T): T;\nexport function dispose<T extends IDisposable>(\n  disposable: T | undefined\n): T | undefined;\nexport function dispose<\n  T extends IDisposable,\n  A extends IterableIterator<T> = IterableIterator<T>\n>(disposables: IterableIterator<T>): A;\nexport function dispose<T extends IDisposable>(disposables: Array<T>): Array<T>;\nexport function dispose<T extends IDisposable>(\n  disposables: ReadonlyArray<T>\n): ReadonlyArray<T>;\nexport function dispose<T extends IDisposable>(\n  arg: T | IterableIterator<T> | undefined\n): any {\n  if (Iterable.is(arg)) {\n    let errors: any[] = [];\n\n    for (const d of arg) {\n      if (d) {\n        markTracked(d);\n        try {\n          d.dispose();\n        } catch (e) {\n          errors.push(e);\n        }\n      }\n    }\n\n    if (errors.length === 1) {\n      throw errors[0];\n    } else if (errors.length > 1) {\n      throw new MultiDisposeError(errors);\n    }\n\n    return Array.isArray(arg) ? [] : arg;\n  } else if (arg) {\n    markTracked(arg);\n    arg.dispose();\n    return arg;\n  }\n}\n\nexport function combinedDisposable(...disposables: IDisposable[]): IDisposable {\n  disposables.forEach(markTracked);\n  return trackDisposable({ dispose: () => dispose(disposables) });\n}\n\nexport function toDisposable(fn: () => void): IDisposable {\n  const self = trackDisposable({\n    dispose: () => {\n      markTracked(self);\n      fn();\n    },\n  });\n  return self;\n}\n\nexport class DisposableStore implements IDisposable {\n  static DISABLE_DISPOSED_WARNING = false;\n\n  private _toDispose = new Set<IDisposable>();\n  private _isDisposed = false;\n\n  /**\n   * Dispose of all registered disposables and mark this object as disposed.\n   *\n   * Any future disposables added to this object will be disposed of on `add`.\n   */\n  public dispose(): void {\n    if (this._isDisposed) {\n      return;\n    }\n\n    markTracked(this);\n    this._isDisposed = true;\n    this.clear();\n  }\n\n  /**\n   * Dispose of all registered disposables but do not mark this object as disposed.\n   */\n  public clear(): void {\n    try {\n      dispose(this._toDispose.values());\n    } finally {\n      this._toDispose.clear();\n    }\n  }\n\n  public add<T extends IDisposable>(t: T): T {\n    if (!t) {\n      return t;\n    }\n    if ((t as unknown as DisposableStore) === this) {\n      throw new Error('Cannot register a disposable on itself!');\n    }\n\n    markTracked(t);\n    if (this._isDisposed) {\n      if (!DisposableStore.DISABLE_DISPOSED_WARNING) {\n        console.warn(\n          new Error(\n            'Trying to add a disposable to a DisposableStore that has already been disposed of. The added object will be leaked!'\n          ).stack\n        );\n      }\n    } else {\n      this._toDispose.add(t);\n    }\n\n    return t;\n  }\n}\n\nexport abstract class Disposable implements IDisposable {\n  static readonly None = Object.freeze<IDisposable>({ dispose() {} });\n\n  private readonly _store = new DisposableStore();\n\n  constructor() {\n    trackDisposable(this);\n  }\n\n  public dispose(): void {\n    markTracked(this);\n\n    this._store.dispose();\n  }\n\n  protected _register<T extends IDisposable>(t: T): T {\n    if ((t as unknown as Disposable) === this) {\n      throw new Error('Cannot register a disposable on itself!');\n    }\n    return this._store.add(t);\n  }\n}\n\n/**\n * Manages the lifecycle of a disposable value that may be changed.\n *\n * This ensures that when the disposable value is changed, the previously held disposable is disposed of. You can\n * also register a `MutableDisposable` on a `Disposable` to ensure it is automatically cleaned up.\n */\nexport class MutableDisposable<T extends IDisposable> implements IDisposable {\n  private _value?: T;\n  private _isDisposed = false;\n\n  constructor() {\n    trackDisposable(this);\n  }\n\n  get value(): T | undefined {\n    return this._isDisposed ? undefined : this._value;\n  }\n\n  set value(value: T | undefined) {\n    if (this._isDisposed || value === this._value) {\n      return;\n    }\n\n    if (this._value) {\n      this._value.dispose();\n    }\n    if (value) {\n      markTracked(value);\n    }\n    this._value = value;\n  }\n\n  clear() {\n    this.value = undefined;\n  }\n\n  dispose(): void {\n    this._isDisposed = true;\n    markTracked(this);\n    if (this._value) {\n      this._value.dispose();\n    }\n    this._value = undefined;\n  }\n}\n\nexport interface IReference<T> extends IDisposable {\n  readonly object: T;\n}\n\nexport abstract class ReferenceCollection<T> {\n  private readonly references: Map<\n    string,\n    { readonly object: T; counter: number }\n  > = new Map();\n\n  acquire(key: string, ...args: any[]): IReference<T> {\n    let reference = this.references.get(key);\n\n    if (!reference) {\n      reference = {\n        counter: 0,\n        object: this.createReferencedObject(key, ...args),\n      };\n      this.references.set(key, reference);\n    }\n\n    const { object } = reference;\n    const dispose = once(() => {\n      if (--reference!.counter === 0) {\n        this.destroyReferencedObject(key, reference!.object);\n        this.references.delete(key);\n      }\n    });\n\n    reference.counter++;\n\n    return { object, dispose };\n  }\n\n  protected abstract createReferencedObject(key: string, ...args: any[]): T;\n  protected abstract destroyReferencedObject(key: string, object: T): void;\n}\n\nexport class ImmortalReference<T> implements IReference<T> {\n  constructor(public object: T) {}\n  dispose(): void {\n    /* noop */\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/linkedList.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nclass Node<E> {\n  static readonly Undefined = new Node<any>(undefined);\n\n  element: E;\n  next: Node<E>;\n  prev: Node<E>;\n\n  constructor(element: E) {\n    this.element = element;\n    this.next = Node.Undefined;\n    this.prev = Node.Undefined;\n  }\n}\n\nexport class LinkedList<E> {\n  private _first: Node<E> = Node.Undefined;\n  private _last: Node<E> = Node.Undefined;\n  private _size: number = 0;\n\n  get size(): number {\n    return this._size;\n  }\n\n  isEmpty(): boolean {\n    return this._first === Node.Undefined;\n  }\n\n  clear(): void {\n    this._first = Node.Undefined;\n    this._last = Node.Undefined;\n    this._size = 0;\n  }\n\n  unshift(element: E): () => void {\n    return this._insert(element, false);\n  }\n\n  push(element: E): () => void {\n    return this._insert(element, true);\n  }\n\n  private _insert(element: E, atTheEnd: boolean): () => void {\n    const newNode = new Node(element);\n    if (this._first === Node.Undefined) {\n      this._first = newNode;\n      this._last = newNode;\n    } else if (atTheEnd) {\n      // push\n      const oldLast = this._last!;\n      this._last = newNode;\n      newNode.prev = oldLast;\n      oldLast.next = newNode;\n    } else {\n      // unshift\n      const oldFirst = this._first;\n      this._first = newNode;\n      newNode.next = oldFirst;\n      oldFirst.prev = newNode;\n    }\n    this._size += 1;\n\n    let didRemove = false;\n    return () => {\n      if (!didRemove) {\n        didRemove = true;\n        this._remove(newNode);\n      }\n    };\n  }\n\n  shift(): E | undefined {\n    if (this._first === Node.Undefined) {\n      return undefined;\n    } else {\n      const res = this._first.element;\n      this._remove(this._first);\n      return res;\n    }\n  }\n\n  pop(): E | undefined {\n    if (this._last === Node.Undefined) {\n      return undefined;\n    } else {\n      const res = this._last.element;\n      this._remove(this._last);\n      return res;\n    }\n  }\n\n  private _remove(node: Node<E>): void {\n    if (node.prev !== Node.Undefined && node.next !== Node.Undefined) {\n      // middle\n      const anchor = node.prev;\n      anchor.next = node.next;\n      node.next.prev = anchor;\n    } else if (node.prev === Node.Undefined && node.next === Node.Undefined) {\n      // only node\n      this._first = Node.Undefined;\n      this._last = Node.Undefined;\n    } else if (node.next === Node.Undefined) {\n      // last\n      this._last = this._last!.prev!;\n      this._last.next = Node.Undefined;\n    } else if (node.prev === Node.Undefined) {\n      // first\n      this._first = this._first!.next!;\n      this._first.prev = Node.Undefined;\n    }\n\n    // done\n    this._size -= 1;\n  }\n\n  *[Symbol.iterator](): Iterator<E> {\n    let node = this._first;\n    while (node !== Node.Undefined) {\n      yield node.element;\n      node = node.next;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/platform.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Copyright (c) Microsoft Corporation. All rights reserved.\n *  Licensed under the MIT License. See License.txt in the project root for license information.\n *--------------------------------------------------------------------------------------------*/\n\n// taken from https://github.com/microsoft/vscode/tree/main/src/vs/base/common\n\nconst LANGUAGE_DEFAULT = 'en';\n\nlet _isWindows = false;\nlet _isMacintosh = false;\nlet _isLinux = false;\nlet _isNative = false;\nlet _isWeb = false;\nlet _isIOS = false;\nlet _locale: string | undefined = undefined;\nlet _language: string = LANGUAGE_DEFAULT;\nlet _translationsConfigFile: string | undefined = undefined;\nlet _userAgent: string | undefined = undefined;\n\ninterface NLSConfig {\n  locale: string;\n  availableLanguages: { [key: string]: string };\n  _translationsConfigFile: string;\n}\n\nexport interface IProcessEnvironment {\n  [key: string]: string;\n}\n\nexport interface INodeProcess {\n  platform: 'win32' | 'linux' | 'darwin';\n  env: IProcessEnvironment;\n  nextTick: Function;\n  versions?: {\n    electron?: string;\n  };\n  sandboxed?: boolean; // Electron\n  type?: string;\n  cwd(): string;\n}\ndeclare const process: INodeProcess;\ndeclare const global: any;\n\ninterface INavigator {\n  userAgent: string;\n  language: string;\n  maxTouchPoints?: number;\n}\ndeclare const navigator: INavigator;\ndeclare const self: any;\n\nconst _globals =\n  typeof self === 'object'\n    ? self\n    : typeof global === 'object'\n    ? global\n    : ({} as any);\n\nlet nodeProcess: INodeProcess | undefined = undefined;\nif (typeof process !== 'undefined') {\n  // Native environment (non-sandboxed)\n  nodeProcess = process;\n} else if (typeof _globals.vscode !== 'undefined') {\n  // Native environment (sandboxed)\n  nodeProcess = _globals.vscode.process;\n}\n\nconst isElectronRenderer =\n  typeof nodeProcess?.versions?.electron === 'string' &&\n  nodeProcess.type === 'renderer';\nexport const isElectronSandboxed = isElectronRenderer && nodeProcess?.sandboxed;\n\n// Web environment\nif (typeof navigator === 'object' && !isElectronRenderer) {\n  _userAgent = navigator.userAgent;\n  _isWindows =\n    _userAgent.indexOf('Windows') >= 0 || _userAgent.indexOf('win32') >= 0;\n  _isMacintosh = _userAgent.indexOf('Macintosh') >= 0;\n  _isIOS =\n    (_userAgent.indexOf('Macintosh') >= 0 ||\n      _userAgent.indexOf('iPad') >= 0 ||\n      _userAgent.indexOf('iPhone') >= 0) &&\n    !!navigator.maxTouchPoints &&\n    navigator.maxTouchPoints > 0;\n  _isLinux = _userAgent.indexOf('Linux') >= 0;\n  _isWeb = true;\n  _locale = navigator.language;\n  _language = _locale;\n}\n\n// Native environment\nelse if (typeof nodeProcess === 'object') {\n  _isWindows = nodeProcess.platform === 'win32';\n  _isMacintosh = nodeProcess.platform === 'darwin';\n  _isLinux = nodeProcess.platform === 'linux';\n  _locale = LANGUAGE_DEFAULT;\n  _language = LANGUAGE_DEFAULT;\n  const rawNlsConfig = nodeProcess.env['VSCODE_NLS_CONFIG'];\n  if (rawNlsConfig) {\n    try {\n      const nlsConfig: NLSConfig = JSON.parse(rawNlsConfig);\n      const resolved = nlsConfig.availableLanguages['*'];\n      _locale = nlsConfig.locale;\n      // VSCode's default language is 'en'\n      _language = resolved ? resolved : LANGUAGE_DEFAULT;\n      _translationsConfigFile = nlsConfig._translationsConfigFile;\n    } catch (e) {}\n  }\n  _isNative = true;\n}\n\n// Unknown environment\nelse {\n  console.error('Unable to resolve platform.');\n}\n\nexport const enum Platform {\n  Web,\n  Mac,\n  Linux,\n  Windows,\n}\nexport function PlatformToString(platform: Platform) {\n  switch (platform) {\n    case Platform.Web:\n      return 'Web';\n    case Platform.Mac:\n      return 'Mac';\n    case Platform.Linux:\n      return 'Linux';\n    case Platform.Windows:\n      return 'Windows';\n  }\n}\n\nlet _platform: Platform = Platform.Web;\nif (_isMacintosh) {\n  _platform = Platform.Mac;\n} else if (_isWindows) {\n  _platform = Platform.Windows;\n} else if (_isLinux) {\n  _platform = Platform.Linux;\n}\n\nexport const isWindows = _isWindows;\nexport const isMacintosh = _isMacintosh;\nexport const isLinux = _isLinux;\nexport const isNative = _isNative;\nexport const isWeb = _isWeb;\nexport const isIOS = _isIOS;\nexport const platform = _platform;\nexport const userAgent = _userAgent;\n\n/**\n * The language used for the user interface. The format of\n * the string is all lower case (e.g. zh-tw for Traditional\n * Chinese)\n */\nexport const language = _language;\n\nexport namespace Language {\n  export function value(): string {\n    return language;\n  }\n\n  export function isDefaultVariant(): boolean {\n    if (language.length === 2) {\n      return language === 'en';\n    } else if (language.length >= 3) {\n      return language[0] === 'e' && language[1] === 'n' && language[2] === '-';\n    } else {\n      return false;\n    }\n  }\n\n  export function isDefault(): boolean {\n    return language === 'en';\n  }\n}\n\n/**\n * The OS locale or the locale specified by --locale. The format of\n * the string is all lower case (e.g. zh-tw for Traditional\n * Chinese). The UI is not necessarily shown in the provided locale.\n */\nexport const locale = _locale;\n\n/**\n * The translatios that are available through language packs.\n */\nexport const translationsConfigFile = _translationsConfigFile;\n\nexport const globals: any = _globals;\n\ninterface ISetImmediate {\n  (callback: (...args: any[]) => void): void;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/snippetParser.test.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Originally taken from https://github.com/microsoft/vscode/blob/d31496c866683bdbccfc85bc11a3107d6c789b52/src/vs/editor/contrib/snippet/test/snippetParser.test.ts\n *  Here was the license:\n *\n *  MIT License\n *\n *\tCopyright (c) 2015 - present Microsoft Corporation\n *\n *\tPermission is hereby granted, free of charge, to any person obtaining a copy\n *\tof this software and associated documentation files (the \"Software\"), to deal\n *\tin the Software without restriction, including without limitation the rights\n *\tto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n *\tcopies of the Software, and to permit persons to whom the Software is\n *\tfurnished to do so, subject to the following conditions:\n *\n *\tThe above copyright notice and this permission notice shall be included in all\n *\tcopies or substantial portions of the Software.\n *\n *\tTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n *\tIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n *\tFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n *\tAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n *\tLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n *\tOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n *\tSOFTWARE.\n *\n *--------------------------------------------------------------------------------------------*/\n\nimport * as assert from 'assert';\nimport { Choice, FormatString, Marker, Placeholder, Scanner, SnippetParser, Text, TextmateSnippet, TokenType, Transform, Variable } from './snippetParser';\n\ndescribe('SnippetParser', () => {\n\n\ttest('Scanner', () => {\n\n\t\tconst scanner = new Scanner();\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('abc');\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('{{abc}}');\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('abc() ');\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Format);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('abc 123');\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Format);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Int);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('$foo');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('$foo_bar');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('$foo-bar');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dash);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('${foo}');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('${1223:foo}');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Int);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Colon);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\n\t\tscanner.text('\\\\${}');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Backslash);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\n\t\tscanner.text('${foo/regex/format/option}');\n\t\tassert.strictEqual(scanner.next().type, TokenType.Dollar);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyOpen);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Forwardslash);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Forwardslash);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.Forwardslash);\n\t\tassert.strictEqual(scanner.next().type, TokenType.VariableName);\n\t\tassert.strictEqual(scanner.next().type, TokenType.CurlyClose);\n\t\tassert.strictEqual(scanner.next().type, TokenType.EOF);\n\t});\n\n\tfunction assertText(value: string, expected: string) {\n\t\tconst p = new SnippetParser();\n\t\tconst actual = p.text(value);\n\t\tassert.strictEqual(actual, expected);\n\t}\n\n\tfunction assertMarker(input: TextmateSnippet | Marker[] | string, ...ctors: Function[]) {\n\t\tlet marker: Marker[];\n\t\tif (input instanceof TextmateSnippet) {\n\t\t\tmarker = input.children;\n\t\t} else if (typeof input === 'string') {\n\t\t\tconst p = new SnippetParser();\n\t\t\tmarker = p.parse(input).children;\n\t\t} else {\n\t\t\tmarker = input;\n\t\t}\n\t\twhile (marker.length > 0) {\n\t\t\tlet m = marker.pop();\n\t\t\tlet ctor = ctors.pop()!;\n\t\t\tassert.ok(m instanceof ctor);\n\t\t}\n\t\tassert.strictEqual(marker.length, ctors.length);\n\t\tassert.strictEqual(marker.length, 0);\n\t}\n\n\tfunction assertTextAndMarker(value: string, escaped: string, ...ctors: Function[]) {\n\t\tassertText(value, escaped);\n\t\tassertMarker(value, ...ctors);\n\t}\n\n\tfunction assertEscaped(value: string, expected: string) {\n\t\tconst actual = SnippetParser.escape(value);\n\t\tassert.strictEqual(actual, expected);\n\t}\n\n\ttest('Parser, escaped', function () {\n\t\tassertEscaped('foo$0', 'foo\\\\$0');\n\t\tassertEscaped('foo\\\\$0', 'foo\\\\\\\\\\\\$0');\n\t\tassertEscaped('f$1oo$0', 'f\\\\$1oo\\\\$0');\n\t\tassertEscaped('${1:foo}$0', '\\\\${1:foo\\\\}\\\\$0');\n\t\tassertEscaped('$', '\\\\$');\n\t});\n\n\ttest('Parser, text', () => {\n\t\tassertText('$', '$');\n\t\tassertText('\\\\\\\\$', '\\\\$');\n\t\tassertText('{', '{');\n\t\tassertText('\\\\}', '}');\n\t\tassertText('\\\\abc', '\\\\abc');\n\t\tassertText('foo${f:\\\\}}bar', 'foo}bar');\n\t\tassertText('\\\\{', '\\\\{');\n\t\tassertText('I need \\\\\\\\\\\\$', 'I need \\\\$');\n\t\tassertText('\\\\', '\\\\');\n\t\tassertText('\\\\{{', '\\\\{{');\n\t\tassertText('{{', '{{');\n\t\tassertText('{{dd', '{{dd');\n\t\tassertText('}}', '}}');\n\t\tassertText('ff}}', 'ff}}');\n\n\t\tassertText('farboo', 'farboo');\n\t\tassertText('far{{}}boo', 'far{{}}boo');\n\t\tassertText('far{{123}}boo', 'far{{123}}boo');\n\t\tassertText('far\\\\{{123}}boo', 'far\\\\{{123}}boo');\n\t\tassertText('far{{id:bern}}boo', 'far{{id:bern}}boo');\n\t\tassertText('far{{id:bern {{basel}}}}boo', 'far{{id:bern {{basel}}}}boo');\n\t\tassertText('far{{id:bern {{id:basel}}}}boo', 'far{{id:bern {{id:basel}}}}boo');\n\t\tassertText('far{{id:bern {{id2:basel}}}}boo', 'far{{id:bern {{id2:basel}}}}boo');\n\t});\n\n\n\ttest('Parser, TM text', () => {\n\t\tassertTextAndMarker('foo${1:bar}}', 'foobar}', Text, Placeholder, Text);\n\t\tassertTextAndMarker('foo${1:bar}${2:foo}}', 'foobarfoo}', Text, Placeholder, Placeholder, Text);\n\n\t\tassertTextAndMarker('foo${1:bar\\\\}${2:foo}}', 'foobar}foo', Text, Placeholder);\n\n\t\tlet [, placeholder] = new SnippetParser().parse('foo${1:bar\\\\}${2:foo}}').children;\n\t\tlet { children } = (<Placeholder>placeholder);\n\n\t\tassert.strictEqual((<Placeholder>placeholder).index, 1);\n\t\tassert.ok(children[0] instanceof Text);\n\t\tassert.strictEqual(children[0].toString(), 'bar}');\n\t\tassert.ok(children[1] instanceof Placeholder);\n\t\tassert.strictEqual(children[1].toString(), 'foo');\n\t});\n\n\ttest('Parser, placeholder', () => {\n\t\tassertTextAndMarker('farboo', 'farboo', Text);\n\t\tassertTextAndMarker('far{{}}boo', 'far{{}}boo', Text);\n\t\tassertTextAndMarker('far{{123}}boo', 'far{{123}}boo', Text);\n\t\tassertTextAndMarker('far\\\\{{123}}boo', 'far\\\\{{123}}boo', Text);\n\t});\n\n\ttest('Parser, literal code', () => {\n\t\tassertTextAndMarker('far`123`boo', 'far`123`boo', Text);\n\t\tassertTextAndMarker('far\\\\`123\\\\`boo', 'far\\\\`123\\\\`boo', Text);\n\t});\n\n\ttest('Parser, variables/tabstop', () => {\n\t\tassertTextAndMarker('$far-boo', '-boo', Variable, Text);\n\t\tassertTextAndMarker('\\\\$far-boo', '$far-boo', Text);\n\t\tassertTextAndMarker('far$farboo', 'far', Text, Variable);\n\t\tassertTextAndMarker('far${farboo}', 'far', Text, Variable);\n\t\tassertTextAndMarker('$123', '', Placeholder);\n\t\tassertTextAndMarker('$farboo', '', Variable);\n\t\tassertTextAndMarker('$far12boo', '', Variable);\n\t\tassertTextAndMarker('000_${far}_000', '000__000', Text, Variable, Text);\n\t\tassertTextAndMarker('FFF_${TM_SELECTED_TEXT}_FFF$0', 'FFF__FFF', Text, Variable, Text, Placeholder);\n\t});\n\n\ttest('Parser, variables/placeholder with defaults', () => {\n\t\tassertTextAndMarker('${name:value}', 'value', Variable);\n\t\tassertTextAndMarker('${1:value}', 'value', Placeholder);\n\t\tassertTextAndMarker('${1:bar${2:foo}bar}', 'barfoobar', Placeholder);\n\n\t\tassertTextAndMarker('${name:value', '${name:value', Text);\n\t\tassertTextAndMarker('${1:bar${2:foobar}', '${1:barfoobar', Text, Placeholder);\n\t});\n\n\ttest('Parser, variable transforms', function () {\n\t\tassertTextAndMarker('${foo///}', '', Variable);\n\t\tassertTextAndMarker('${foo/regex/format/gmi}', '', Variable);\n\t\tassertTextAndMarker('${foo/([A-Z][a-z])/format/}', '', Variable);\n\n\t\t// invalid regex\n\t\tassertTextAndMarker('${foo/([A-Z][a-z])/format/GMI}', '${foo/([A-Z][a-z])/format/GMI}', Text);\n\t\tassertTextAndMarker('${foo/([A-Z][a-z])/format/funky}', '${foo/([A-Z][a-z])/format/funky}', Text);\n\t\tassertTextAndMarker('${foo/([A-Z][a-z]/format/}', '${foo/([A-Z][a-z]/format/}', Text);\n\n\t\t// tricky regex\n\t\tassertTextAndMarker('${foo/m\\\\/atch/$1/i}', '', Variable);\n\t\tassertMarker('${foo/regex\\/format/options}', Text);\n\n\t\t// incomplete\n\t\tassertTextAndMarker('${foo///', '${foo///', Text);\n\t\tassertTextAndMarker('${foo/regex/format/options', '${foo/regex/format/options', Text);\n\n\t\t// format string\n\t\tassertMarker('${foo/.*/${0:fooo}/i}', Variable);\n\t\tassertMarker('${foo/.*/${1}/i}', Variable);\n\t\tassertMarker('${foo/.*/$1/i}', Variable);\n\t\tassertMarker('${foo/.*/This-$1-encloses/i}', Variable);\n\t\tassertMarker('${foo/.*/complex${1:else}/i}', Variable);\n\t\tassertMarker('${foo/.*/complex${1:-else}/i}', Variable);\n\t\tassertMarker('${foo/.*/complex${1:+if}/i}', Variable);\n\t\tassertMarker('${foo/.*/complex${1:?if:else}/i}', Variable);\n\t\tassertMarker('${foo/.*/complex${1:/upcase}/i}', Variable);\n\n\t});\n\n\ttest('Parser, placeholder transforms', function () {\n\t\tassertTextAndMarker('${1///}', '', Placeholder);\n\t\tassertTextAndMarker('${1/regex/format/gmi}', '', Placeholder);\n\t\tassertTextAndMarker('${1/([A-Z][a-z])/format/}', '', Placeholder);\n\n\t\t// tricky regex\n\t\tassertTextAndMarker('${1/m\\\\/atch/$1/i}', '', Placeholder);\n\t\tassertMarker('${1/regex\\/format/options}', Text);\n\n\t\t// incomplete\n\t\tassertTextAndMarker('${1///', '${1///', Text);\n\t\tassertTextAndMarker('${1/regex/format/options', '${1/regex/format/options', Text);\n\t});\n\n\ttest('No way to escape forward slash in snippet regex #36715', function () {\n\t\tassertMarker('${TM_DIRECTORY/src\\\\//$1/}', Variable);\n\t});\n\n\ttest('No way to escape forward slash in snippet format section #37562', function () {\n\t\tassertMarker('${TM_SELECTED_TEXT/a/\\\\/$1/g}', Variable);\n\t\tassertMarker('${TM_SELECTED_TEXT/a/in\\\\/$1ner/g}', Variable);\n\t\tassertMarker('${TM_SELECTED_TEXT/a/end\\\\//g}', Variable);\n\t});\n\n\ttest('Parser, placeholder with choice', () => {\n\n\t\tassertTextAndMarker('${1|one,two,three|}', 'one', Placeholder);\n\t\tassertTextAndMarker('${1|one|}', 'one', Placeholder);\n\t\tassertTextAndMarker('${1|one1,two2|}', 'one1', Placeholder);\n\t\tassertTextAndMarker('${1|one1\\\\,two2|}', 'one1,two2', Placeholder);\n\t\tassertTextAndMarker('${1|one1\\\\|two2|}', 'one1|two2', Placeholder);\n\t\tassertTextAndMarker('${1|one1\\\\atwo2|}', 'one1\\\\atwo2', Placeholder);\n\t\tassertTextAndMarker('${1|one,two,three,|}', '${1|one,two,three,|}', Text);\n\t\tassertTextAndMarker('${1|one,', '${1|one,', Text);\n\n\t\tconst p = new SnippetParser();\n\t\tconst snippet = p.parse('${1|one,two,three|}');\n\t\tassertMarker(snippet, Placeholder);\n\t\tconst expected = [Placeholder, Text, Text, Text];\n\t\tsnippet.walk(marker => {\n\t\t\tassert.strictEqual(marker, expected.shift());\n\t\t\treturn true;\n\t\t});\n\t});\n\n\ttest('Snippet choices: unable to escape comma and pipe, #31521', function () {\n\t\tassertTextAndMarker('console.log(${1|not\\\\, not, five, 5, 1   23|});', 'console.log(not, not);', Text, Placeholder, Text);\n\t});\n\n\ttest('Marker, toTextmateString()', function () {\n\n\t\tfunction assertTextsnippetString(input: string, expected: string): void {\n\t\t\tconst snippet = new SnippetParser().parse(input);\n\t\t\tconst actual = snippet.toTextmateString();\n\t\t\tassert.strictEqual(actual, expected);\n\t\t}\n\n\t\tassertTextsnippetString('$1', '$1');\n\t\tassertTextsnippetString('\\\\$1', '\\\\$1');\n\t\tassertTextsnippetString('console.log(${1|not\\\\, not, five, 5, 1   23|});', 'console.log(${1|not\\\\, not, five, 5, 1   23|});');\n\t\tassertTextsnippetString('console.log(${1|not\\\\, not, \\\\| five, 5, 1   23|});', 'console.log(${1|not\\\\, not, \\\\| five, 5, 1   23|});');\n\t\tassertTextsnippetString('this is text', 'this is text');\n\t\tassertTextsnippetString('this ${1:is ${2:nested with $var}}', 'this ${1:is ${2:nested with ${var}}}');\n\t\tassertTextsnippetString('this ${1:is ${2:nested with $var}}}', 'this ${1:is ${2:nested with ${var}}}\\\\}');\n\t});\n\n\ttest('Marker, toTextmateString() <-> identity', function () {\n\n\t\tfunction assertIdent(input: string): void {\n\t\t\t// full loop: (1) parse input, (2) generate textmate string, (3) parse, (4) ensure both trees are equal\n\t\t\tconst snippet = new SnippetParser().parse(input);\n\t\t\tconst input2 = snippet.toTextmateString();\n\t\t\tconst snippet2 = new SnippetParser().parse(input2);\n\n\t\t\tfunction checkCheckChildren(marker1: Marker, marker2: Marker) {\n\t\t\t\tassert.ok(marker1 instanceof Object.getPrototypeOf(marker2).constructor);\n\t\t\t\tassert.ok(marker2 instanceof Object.getPrototypeOf(marker1).constructor);\n\n\t\t\t\tassert.strictEqual(marker1.children.length, marker2.children.length);\n\t\t\t\tassert.strictEqual(marker1.toString(), marker2.toString());\n\n\t\t\t\tfor (let i = 0; i < marker1.children.length; i++) {\n\t\t\t\t\tcheckCheckChildren(marker1.children[i], marker2.children[i]);\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tcheckCheckChildren(snippet, snippet2);\n\t\t}\n\n\t\tassertIdent('$1');\n\t\tassertIdent('\\\\$1');\n\t\tassertIdent('console.log(${1|not\\\\, not, five, 5, 1   23|});');\n\t\tassertIdent('console.log(${1|not\\\\, not, \\\\| five, 5, 1   23|});');\n\t\tassertIdent('this is text');\n\t\tassertIdent('this ${1:is ${2:nested with $var}}');\n\t\tassertIdent('this ${1:is ${2:nested with $var}}}');\n\t\tassertIdent('this ${1:is ${2:nested with $var}} and repeating $1');\n\t});\n\n\ttest('Parser, choice marker', () => {\n\t\tconst { placeholders } = new SnippetParser().parse('${1|one,two,three|}');\n\n\t\tassert.strictEqual(placeholders.length, 1);\n\t\tassert.ok(placeholders[0].choice instanceof Choice);\n\t\tassert.ok(placeholders[0].children[0] instanceof Choice);\n\t\tassert.strictEqual((<Choice>placeholders[0].children[0]).options.length, 3);\n\n\t\tassertText('${1|one,two,three|}', 'one');\n\t\tassertText('\\\\${1|one,two,three|}', '${1|one,two,three|}');\n\t\tassertText('${1\\\\|one,two,three|}', '${1\\\\|one,two,three|}');\n\t\tassertText('${1||}', '${1||}');\n\t});\n\n\ttest('Backslash character escape in choice tabstop doesn\\'t work #58494', function () {\n\n\t\tconst { placeholders } = new SnippetParser().parse('${1|\\\\,,},$,\\\\|,\\\\\\\\|}');\n\t\tassert.strictEqual(placeholders.length, 1);\n\t\tassert.ok(placeholders[0].choice instanceof Choice);\n\t});\n\n\ttest('Parser, only textmate', () => {\n\t\tconst p = new SnippetParser();\n\t\tassertMarker(p.parse('far{{}}boo'), Text);\n\t\tassertMarker(p.parse('far{{123}}boo'), Text);\n\t\tassertMarker(p.parse('far\\\\{{123}}boo'), Text);\n\n\t\tassertMarker(p.parse('far$0boo'), Text, Placeholder, Text);\n\t\tassertMarker(p.parse('far${123}boo'), Text, Placeholder, Text);\n\t\tassertMarker(p.parse('far\\\\${123}boo'), Text);\n\t});\n\n\ttest('Parser, real world', () => {\n\t\tlet marker = new SnippetParser().parse('console.warn(${1: $TM_SELECTED_TEXT })').children;\n\n\t\tassert.strictEqual(marker[0].toString(), 'console.warn(');\n\t\tassert.ok(marker[1] instanceof Placeholder);\n\t\tassert.strictEqual(marker[2].toString(), ')');\n\n\t\tconst placeholder = <Placeholder>marker[1];\n\t\tassert.strictEqual(placeholder.index, 1);\n\t\tassert.strictEqual(placeholder.children.length, 3);\n\t\tassert.ok(placeholder.children[0] instanceof Text);\n\t\tassert.ok(placeholder.children[1] instanceof Variable);\n\t\tassert.ok(placeholder.children[2] instanceof Text);\n\t\tassert.strictEqual(placeholder.children[0].toString(), ' ');\n\t\tassert.strictEqual(placeholder.children[1].toString(), '');\n\t\tassert.strictEqual(placeholder.children[2].toString(), ' ');\n\n\t\tconst nestedVariable = <Variable>placeholder.children[1];\n\t\tassert.strictEqual(nestedVariable.name, 'TM_SELECTED_TEXT');\n\t\tassert.strictEqual(nestedVariable.children.length, 0);\n\n\t\tmarker = new SnippetParser().parse('$TM_SELECTED_TEXT').children;\n\t\tassert.strictEqual(marker.length, 1);\n\t\tassert.ok(marker[0] instanceof Variable);\n\t});\n\n\ttest('Parser, transform example', () => {\n\t\tlet { children } = new SnippetParser().parse('${1:name} : ${2:type}${3/\\\\s:=(.*)/${1:+ :=}${1}/};\\n$0');\n\n\t\t//${1:name}\n\t\tassert.ok(children[0] instanceof Placeholder);\n\t\tassert.strictEqual(children[0].children.length, 1);\n\t\tassert.strictEqual(children[0].children[0].toString(), 'name');\n\t\tassert.strictEqual((<Placeholder>children[0]).transform, undefined);\n\n\t\t// :\n\t\tassert.ok(children[1] instanceof Text);\n\t\tassert.strictEqual(children[1].toString(), ' : ');\n\n\t\t//${2:type}\n\t\tassert.ok(children[2] instanceof Placeholder);\n\t\tassert.strictEqual(children[2].children.length, 1);\n\t\tassert.strictEqual(children[2].children[0].toString(), 'type');\n\n\t\t//${3/\\\\s:=(.*)/${1:+ :=}${1}/}\n\t\tassert.ok(children[3] instanceof Placeholder);\n\t\tassert.strictEqual(children[3].children.length, 0);\n\t\tassert.notStrictEqual((<Placeholder>children[3]).transform, undefined);\n\t\tlet transform = (<Placeholder>children[3]).transform!;\n\t\tassert.deepStrictEqual(transform.regexp.source, /\\s:=(.*)/.source);\n\t\tassert.strictEqual(transform.children.length, 2);\n\t\tassert.ok(transform.children[0] instanceof FormatString);\n\t\tassert.strictEqual((<FormatString>transform.children[0]).index, 1);\n\t\tassert.strictEqual((<FormatString>transform.children[0]).ifValue, ' :=');\n\t\tassert.ok(transform.children[1] instanceof FormatString);\n\t\tassert.strictEqual((<FormatString>transform.children[1]).index, 1);\n\t\tassert.ok(children[4] instanceof Text);\n\t\tassert.strictEqual(children[4].toString(), ';\\n');\n\n\t});\n\n\t// TODO @jrieken making this strictEqul causes circular json conversion errors\n\ttest('Parser, default placeholder values', () => {\n\n\t\tassertMarker('errorContext: `${1:err}`, error: $1', Text, Placeholder, Text, Placeholder);\n\n\t\tconst [, p1, , p2] = new SnippetParser().parse('errorContext: `${1:err}`, error:$1').children;\n\n\t\tassert.strictEqual((<Placeholder>p1).index, 1);\n\t\tassert.strictEqual((<Placeholder>p1).children.length, 1);\n\t\tassert.strictEqual((<Text>(<Placeholder>p1).children[0]).toString(), 'err');\n\n\t\tassert.strictEqual((<Placeholder>p2).index, 1);\n\t\tassert.strictEqual((<Placeholder>p2).children.length, 1);\n\t\tassert.strictEqual((<Text>(<Placeholder>p2).children[0]).toString(), 'err');\n\t});\n\n\t// TODO @jrieken making this strictEqul causes circular json conversion errors\n\ttest('Parser, default placeholder values and one transform', () => {\n\n\t\tassertMarker('errorContext: `${1:err}`, error: ${1/err/ok/}', Text, Placeholder, Text, Placeholder);\n\n\t\tconst [, p3, , p4] = new SnippetParser().parse('errorContext: `${1:err}`, error:${1/err/ok/}').children;\n\n\t\tassert.strictEqual((<Placeholder>p3).index, 1);\n\t\tassert.strictEqual((<Placeholder>p3).children.length, 1);\n\t\tassert.strictEqual((<Text>(<Placeholder>p3).children[0]).toString(), 'err');\n\t\tassert.strictEqual((<Placeholder>p3).transform, undefined);\n\n\t\tassert.strictEqual((<Placeholder>p4).index, 1);\n\t\tassert.strictEqual((<Placeholder>p4).children.length, 1);\n\t\tassert.strictEqual((<Text>(<Placeholder>p4).children[0]).toString(), 'err');\n\t\tassert.notStrictEqual((<Placeholder>p4).transform, undefined);\n\t});\n\n\ttest('Repeated snippet placeholder should always inherit, #31040', function () {\n\t\tassertText('${1:foo}-abc-$1', 'foo-abc-foo');\n\t\tassertText('${1:foo}-abc-${1}', 'foo-abc-foo');\n\t\tassertText('${1:foo}-abc-${1:bar}', 'foo-abc-foo');\n\t\tassertText('${1}-abc-${1:foo}', 'foo-abc-foo');\n\t});\n\n\ttest('backspace esapce in TM only, #16212', () => {\n\t\tconst actual = new SnippetParser().text('Foo \\\\\\\\${abc}bar');\n\t\tassert.strictEqual(actual, 'Foo \\\\bar');\n\t});\n\n\ttest('colon as variable/placeholder value, #16717', () => {\n\t\tlet actual = new SnippetParser().text('${TM_SELECTED_TEXT:foo:bar}');\n\t\tassert.strictEqual(actual, 'foo:bar');\n\n\t\tactual = new SnippetParser().text('${1:foo:bar}');\n\t\tassert.strictEqual(actual, 'foo:bar');\n\t});\n\n\ttest('incomplete placeholder', () => {\n\t\tassertTextAndMarker('${1:}', '', Placeholder);\n\t});\n\n\ttest('marker#len', () => {\n\n\t\tfunction assertLen(template: string, ...lengths: number[]): void {\n\t\t\tconst snippet = new SnippetParser().parse(template, true);\n\t\t\tsnippet.walk(m => {\n\t\t\t\tconst expected = lengths.shift();\n\t\t\t\tassert.strictEqual(m.len(), expected);\n\t\t\t\treturn true;\n\t\t\t});\n\t\t\tassert.strictEqual(lengths.length, 0);\n\t\t}\n\n\t\tassertLen('text$0', 4, 0);\n\t\tassertLen('$1text$0', 0, 4, 0);\n\t\tassertLen('te$1xt$0', 2, 0, 2, 0);\n\t\tassertLen('errorContext: `${1:err}`, error: $0', 15, 0, 3, 10, 0);\n\t\tassertLen('errorContext: `${1:err}`, error: $1$0', 15, 0, 3, 10, 0, 3, 0);\n\t\tassertLen('$TM_SELECTED_TEXT$0', 0, 0);\n\t\tassertLen('${TM_SELECTED_TEXT:def}$0', 0, 3, 0);\n\t});\n\n\ttest('parser, parent node', function () {\n\t\tlet snippet = new SnippetParser().parse('This ${1:is ${2:nested}}$0', true);\n\n\t\tassert.strictEqual(snippet.placeholders.length, 3);\n\t\tlet [first, second] = snippet.placeholders;\n\t\tassert.strictEqual(first.index, 1);\n\t\tassert.strictEqual(second.index, 2);\n\t\tassert.ok(second.parent === first);\n\t\tassert.ok(first.parent === snippet);\n\n\t\tsnippet = new SnippetParser().parse('${VAR:default${1:value}}$0', true);\n\t\tassert.strictEqual(snippet.placeholders.length, 2);\n\t\t[first] = snippet.placeholders;\n\t\tassert.strictEqual(first.index, 1);\n\n\t\tassert.ok(snippet.children[0] instanceof Variable);\n\t\tassert.ok(first.parent === snippet.children[0]);\n\t});\n\n\ttest('TextmateSnippet#enclosingPlaceholders', () => {\n\t\tlet snippet = new SnippetParser().parse('This ${1:is ${2:nested}}$0', true);\n\t\tlet [first, second] = snippet.placeholders;\n\n\t\tassert.deepStrictEqual(snippet.enclosingPlaceholders(first), []);\n\t\tassert.deepStrictEqual(snippet.enclosingPlaceholders(second), [first]);\n\t});\n\n\ttest('TextmateSnippet#offset', () => {\n\t\tlet snippet = new SnippetParser().parse('te$1xt', true);\n\t\tassert.strictEqual(snippet.offset(snippet.children[0]), 0);\n\t\tassert.strictEqual(snippet.offset(snippet.children[1]), 2);\n\t\tassert.strictEqual(snippet.offset(snippet.children[2]), 2);\n\n\t\tsnippet = new SnippetParser().parse('${TM_SELECTED_TEXT:def}', true);\n\t\tassert.strictEqual(snippet.offset(snippet.children[0]), 0);\n\t\tassert.strictEqual(snippet.offset((<Variable>snippet.children[0]).children[0]), 0);\n\n\t\t// forgein marker\n\t\tassert.strictEqual(snippet.offset(new Text('foo')), -1);\n\t});\n\n\ttest('TextmateSnippet#placeholder', () => {\n\t\tlet snippet = new SnippetParser().parse('te$1xt$0', true);\n\t\tlet placeholders = snippet.placeholders;\n\t\tassert.strictEqual(placeholders.length, 2);\n\n\t\tsnippet = new SnippetParser().parse('te$1xt$1$0', true);\n\t\tplaceholders = snippet.placeholders;\n\t\tassert.strictEqual(placeholders.length, 3);\n\n\n\t\tsnippet = new SnippetParser().parse('te$1xt$2$0', true);\n\t\tplaceholders = snippet.placeholders;\n\t\tassert.strictEqual(placeholders.length, 3);\n\n\t\tsnippet = new SnippetParser().parse('${1:bar${2:foo}bar}$0', true);\n\t\tplaceholders = snippet.placeholders;\n\t\tassert.strictEqual(placeholders.length, 3);\n\t});\n\n\ttest('TextmateSnippet#replace 1/2', function () {\n\t\tlet snippet = new SnippetParser().parse('aaa${1:bbb${2:ccc}}$0', true);\n\n\t\tassert.strictEqual(snippet.placeholders.length, 3);\n\t\tconst [, second] = snippet.placeholders;\n\t\tassert.strictEqual(second.index, 2);\n\n\t\tconst enclosing = snippet.enclosingPlaceholders(second);\n\t\tassert.strictEqual(enclosing.length, 1);\n\t\tassert.strictEqual(enclosing[0].index, 1);\n\n\t\tlet nested = new SnippetParser().parse('ddd$1eee$0', true);\n\t\tsnippet.replace(second, nested.children);\n\n\t\tassert.strictEqual(snippet.toString(), 'aaabbbdddeee');\n\t\tassert.strictEqual(snippet.placeholders.length, 4);\n\t\tassert.strictEqual(snippet.placeholders[0].index, 1);\n\t\tassert.strictEqual(snippet.placeholders[1].index, 1);\n\t\tassert.strictEqual(snippet.placeholders[2].index, 0);\n\t\tassert.strictEqual(snippet.placeholders[3].index, 0);\n\n\t\tconst newEnclosing = snippet.enclosingPlaceholders(snippet.placeholders[1]);\n\t\tassert.ok(newEnclosing[0] === snippet.placeholders[0]);\n\t\tassert.strictEqual(newEnclosing.length, 1);\n\t\tassert.strictEqual(newEnclosing[0].index, 1);\n\t});\n\n\ttest('TextmateSnippet#replace 2/2', function () {\n\t\tlet snippet = new SnippetParser().parse('aaa${1:bbb${2:ccc}}$0', true);\n\n\t\tassert.strictEqual(snippet.placeholders.length, 3);\n\t\tconst [, second] = snippet.placeholders;\n\t\tassert.strictEqual(second.index, 2);\n\n\t\tlet nested = new SnippetParser().parse('dddeee$0', true);\n\t\tsnippet.replace(second, nested.children);\n\n\t\tassert.strictEqual(snippet.toString(), 'aaabbbdddeee');\n\t\tassert.strictEqual(snippet.placeholders.length, 3);\n\t});\n\n\ttest('Snippet order for placeholders, #28185', function () {\n\n\t\tconst _10 = new Placeholder(10);\n\t\tconst _2 = new Placeholder(2);\n\n\t\tassert.strictEqual(Placeholder.compareByIndex(_10, _2), 1);\n\t});\n\n\ttest('Maximum call stack size exceeded, #28983', function () {\n\t\tnew SnippetParser().parse('${1:${foo:${1}}}');\n\t});\n\n\ttest('Snippet can freeze the editor, #30407', function () {\n\n\t\tconst seen = new Set<Marker>();\n\n\t\tseen.clear();\n\t\tnew SnippetParser().parse('class ${1:${TM_FILENAME/(?:\\\\A|_)([A-Za-z0-9]+)(?:\\\\.rb)?/(?2::\\\\u$1)/g}} < ${2:Application}Controller\\n  $3\\nend').walk(marker => {\n\t\t\tassert.ok(!seen.has(marker));\n\t\t\tseen.add(marker);\n\t\t\treturn true;\n\t\t});\n\n\t\tseen.clear();\n\t\tnew SnippetParser().parse('${1:${FOO:abc$1def}}').walk(marker => {\n\t\t\tassert.ok(!seen.has(marker));\n\t\t\tseen.add(marker);\n\t\t\treturn true;\n\t\t});\n\t});\n\n\ttest('Snippets: make parser ignore `${0|choice|}`, #31599', function () {\n\t\tassertTextAndMarker('${0|foo,bar|}', '${0|foo,bar|}', Text);\n\t\tassertTextAndMarker('${1|foo,bar|}', 'foo', Placeholder);\n\t});\n\n\n\ttest('Transform -> FormatString#resolve', function () {\n\n\t\t// shorthand functions\n\t\tassert.strictEqual(new FormatString(1, 'upcase').resolve('foo'), 'FOO');\n\t\tassert.strictEqual(new FormatString(1, 'downcase').resolve('FOO'), 'foo');\n\t\tassert.strictEqual(new FormatString(1, 'capitalize').resolve('bar'), 'Bar');\n\t\tassert.strictEqual(new FormatString(1, 'capitalize').resolve('bar no repeat'), 'Bar no repeat');\n\t\tassert.strictEqual(new FormatString(1, 'pascalcase').resolve('bar-foo'), 'BarFoo');\n\t\tassert.strictEqual(new FormatString(1, 'pascalcase').resolve('bar-42-foo'), 'Bar42Foo');\n\t\tassert.strictEqual(new FormatString(1, 'camelcase').resolve('bar-foo'), 'barFoo');\n\t\tassert.strictEqual(new FormatString(1, 'camelcase').resolve('bar-42-foo'), 'bar42Foo');\n\t\tassert.strictEqual(new FormatString(1, 'notKnown').resolve('input'), 'input');\n\n\t\t// if\n\t\tassert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve(undefined), '');\n\t\tassert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve(''), '');\n\t\tassert.strictEqual(new FormatString(1, undefined, 'foo', undefined).resolve('bar'), 'foo');\n\n\t\t// else\n\t\tassert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve(undefined), 'foo');\n\t\tassert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve(''), 'foo');\n\t\tassert.strictEqual(new FormatString(1, undefined, undefined, 'foo').resolve('bar'), 'bar');\n\n\t\t// if-else\n\t\tassert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve(undefined), 'foo');\n\t\tassert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve(''), 'foo');\n\t\tassert.strictEqual(new FormatString(1, undefined, 'bar', 'foo').resolve('baz'), 'bar');\n\t});\n\n\ttest('Snippet variable transformation doesn\\'t work if regex is complicated and snippet body contains \\'$$\\' #55627', function () {\n\t\tconst snippet = new SnippetParser().parse('const fileName = \"${TM_FILENAME/(.*)\\\\..+$/$1/}\"');\n\t\tassert.strictEqual(snippet.toTextmateString(), 'const fileName = \"${TM_FILENAME/(.*)\\\\..+$/${1}/}\"');\n\t});\n\n\ttest('[BUG] HTML attribute suggestions: Snippet session does not have end-position set, #33147', function () {\n\n\t\tconst { placeholders } = new SnippetParser().parse('src=\"$1\"', true);\n\t\tconst [first, second] = placeholders;\n\n\t\tassert.strictEqual(placeholders.length, 2);\n\t\tassert.strictEqual(first.index, 1);\n\t\tassert.strictEqual(second.index, 0);\n\n\t});\n\n\ttest('Snippet optional transforms are not applied correctly when reusing the same variable, #37702', function () {\n\n\t\tconst transform = new Transform();\n\t\ttransform.appendChild(new FormatString(1, 'upcase'));\n\t\ttransform.appendChild(new FormatString(2, 'upcase'));\n\t\ttransform.regexp = /^(.)|-(.)/g;\n\n\t\tassert.strictEqual(transform.resolve('my-file-name'), 'MyFileName');\n\n\t\tconst clone = transform.clone();\n\t\tassert.strictEqual(clone.resolve('my-file-name'), 'MyFileName');\n\t});\n\n\ttest('problem with snippets regex #40570', function () {\n\n\t\tconst snippet = new SnippetParser().parse('${TM_DIRECTORY/.*src[\\\\/](.*)/$1/}');\n\t\tassertMarker(snippet, Variable);\n\t});\n\n\ttest('Variable transformation doesn\\'t work if undefined variables are used in the same snippet #51769', function () {\n\t\tlet transform = new Transform();\n\t\ttransform.appendChild(new Text('bar'));\n\t\ttransform.regexp = new RegExp('foo', 'gi');\n\t\tassert.strictEqual(transform.toTextmateString(), '/foo/bar/ig');\n\t});\n\n\ttest('Snippet parser freeze #53144', function () {\n\t\tlet snippet = new SnippetParser().parse('${1/(void$)|(.+)/${1:?-\\treturn nil;}/}');\n\t\tassertMarker(snippet, Placeholder);\n\t});\n\n\ttest('snippets variable not resolved in JSON proposal #52931', function () {\n\t\tassertTextAndMarker('FOO${1:/bin/bash}', 'FOO/bin/bash', Text, Placeholder);\n\t});\n\n\ttest('Mirroring sequence of nested placeholders not selected properly on backjumping #58736', function () {\n\t\tlet snippet = new SnippetParser().parse('${3:nest1 ${1:nest2 ${2:nest3}}} $3');\n\t\tassert.strictEqual(snippet.children.length, 3);\n\t\tassert.ok(snippet.children[0] instanceof Placeholder);\n\t\tassert.ok(snippet.children[1] instanceof Text);\n\t\tassert.ok(snippet.children[2] instanceof Placeholder);\n\n\t\tfunction assertParent(marker: Marker) {\n\t\t\tmarker.children.forEach(assertParent);\n\t\t\tif (!(marker instanceof Placeholder)) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tlet found = false;\n\t\t\tlet m: Marker = marker;\n\t\t\twhile (m && !found) {\n\t\t\t\tif (m.parent === snippet) {\n\t\t\t\t\tfound = true;\n\t\t\t\t}\n\t\t\t\tm = m.parent;\n\t\t\t}\n\t\t\tassert.ok(found);\n\t\t}\n\t\tlet [, , clone] = snippet.children;\n\t\tassertParent(clone);\n\t});\n\n\ttest('Backspace can\\'t be escaped in snippet variable transforms #65412', function () {\n\n\t\tlet snippet = new SnippetParser().parse('namespace ${TM_DIRECTORY/[\\\\/]/\\\\\\\\/g};');\n\t\tassertMarker(snippet, Text, Variable, Text);\n\t});\n\n\ttest('Snippet cannot escape closing bracket inside conditional insertion variable replacement #78883', function () {\n\n\t\tlet snippet = new SnippetParser().parse('${TM_DIRECTORY/(.+)/${1:+import { hello \\\\} from world}/}');\n\t\tlet variable = <Variable>snippet.children[0];\n\t\tassert.strictEqual(snippet.children.length, 1);\n\t\tassert.ok(variable instanceof Variable);\n\t\tassert.ok(variable.transform);\n\t\tassert.strictEqual(variable.transform!.children.length, 1);\n\t\tassert.ok(variable.transform!.children[0] instanceof FormatString);\n\t\tassert.strictEqual((<FormatString>variable.transform!.children[0]).ifValue, 'import { hello } from world');\n\t\tassert.strictEqual((<FormatString>variable.transform!.children[0]).elseValue, undefined);\n\t});\n\n\ttest('Snippet escape backslashes inside conditional insertion variable replacement #80394', function () {\n\n\t\tlet snippet = new SnippetParser().parse('${CURRENT_YEAR/(.+)/${1:+\\\\\\\\}/}');\n\t\tlet variable = <Variable>snippet.children[0];\n\t\tassert.strictEqual(snippet.children.length, 1);\n\t\tassert.ok(variable instanceof Variable);\n\t\tassert.ok(variable.transform);\n\t\tassert.strictEqual(variable.transform!.children.length, 1);\n\t\tassert.ok(variable.transform!.children[0] instanceof FormatString);\n\t\tassert.strictEqual((<FormatString>variable.transform!.children[0]).ifValue, '\\\\');\n\t\tassert.strictEqual((<FormatString>variable.transform!.children[0]).elseValue, undefined);\n\t});\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/common/snippetParser.ts",
    "content": "/*---------------------------------------------------------------------------------------------\n *  Originally taken from https://github.com/microsoft/vscode/blob/ea132060dd8c71baa6b2f77e46287710b888bb01/src/vs/editor/contrib/snippet/snippetParser.ts\n *  Here was the license:\n *\n *  MIT License\n *\n *\tCopyright (c) 2015 - present Microsoft Corporation\n *\n *\tPermission is hereby granted, free of charge, to any person obtaining a copy\n *\tof this software and associated documentation files (the \"Software\"), to deal\n *\tin the Software without restriction, including without limitation the rights\n *\tto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n *\tcopies of the Software, and to permit persons to whom the Software is\n *\tfurnished to do so, subject to the following conditions:\n *\n *\tThe above copyright notice and this permission notice shall be included in all\n *\tcopies or substantial portions of the Software.\n *\n *\tTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n *\tIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n *\tFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n *\tAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n *\tLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n *\tOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\n *\tSOFTWARE.\n *\n *--------------------------------------------------------------------------------------------*/\n\nimport { CharCode } from './charCode';\n\nexport const enum TokenType {\n\tDollar,\n\tColon,\n\tComma,\n\tCurlyOpen,\n\tCurlyClose,\n\tBackslash,\n\tForwardslash,\n\tPipe,\n\tInt,\n\tVariableName,\n\tFormat,\n\tPlus,\n\tDash,\n\tQuestionMark,\n\tEOF\n}\n\nexport interface Token {\n\ttype: TokenType;\n\tpos: number;\n\tlen: number;\n}\n\n\nexport class Scanner {\n\n\tprivate static _table: { [ch: number]: TokenType } = {\n\t\t[CharCode.DollarSign]: TokenType.Dollar,\n\t\t[CharCode.Colon]: TokenType.Colon,\n\t\t[CharCode.Comma]: TokenType.Comma,\n\t\t[CharCode.OpenCurlyBrace]: TokenType.CurlyOpen,\n\t\t[CharCode.CloseCurlyBrace]: TokenType.CurlyClose,\n\t\t[CharCode.Backslash]: TokenType.Backslash,\n\t\t[CharCode.Slash]: TokenType.Forwardslash,\n\t\t[CharCode.Pipe]: TokenType.Pipe,\n\t\t[CharCode.Plus]: TokenType.Plus,\n\t\t[CharCode.Dash]: TokenType.Dash,\n\t\t[CharCode.QuestionMark]: TokenType.QuestionMark,\n\t};\n\n\tstatic isDigitCharacter(ch: number): boolean {\n\t\treturn ch >= CharCode.Digit0 && ch <= CharCode.Digit9;\n\t}\n\n\tstatic isVariableCharacter(ch: number): boolean {\n\t\treturn ch === CharCode.Underline\n\t\t\t|| (ch >= CharCode.a && ch <= CharCode.z)\n\t\t\t|| (ch >= CharCode.A && ch <= CharCode.Z);\n\t}\n\n\tvalue: string = '';\n\tpos: number = 0;\n\n\ttext(value: string) {\n\t\tthis.value = value;\n\t\tthis.pos = 0;\n\t}\n\n\ttokenText(token: Token): string {\n\t\treturn this.value.substr(token.pos, token.len);\n\t}\n\n\tnext(): Token {\n\n\t\tif (this.pos >= this.value.length) {\n\t\t\treturn { type: TokenType.EOF, pos: this.pos, len: 0 };\n\t\t}\n\n\t\tlet pos = this.pos;\n\t\tlet len = 0;\n\t\tlet ch = this.value.charCodeAt(pos);\n\t\tlet type: TokenType;\n\n\t\t// static types\n\t\ttype = Scanner._table[ch];\n\t\tif (typeof type === 'number') {\n\t\t\tthis.pos += 1;\n\t\t\treturn { type, pos, len: 1 };\n\t\t}\n\n\t\t// number\n\t\tif (Scanner.isDigitCharacter(ch)) {\n\t\t\ttype = TokenType.Int;\n\t\t\tdo {\n\t\t\t\tlen += 1;\n\t\t\t\tch = this.value.charCodeAt(pos + len);\n\t\t\t} while (Scanner.isDigitCharacter(ch));\n\n\t\t\tthis.pos += len;\n\t\t\treturn { type, pos, len };\n\t\t}\n\n\t\t// variable name\n\t\tif (Scanner.isVariableCharacter(ch)) {\n\t\t\ttype = TokenType.VariableName;\n\t\t\tdo {\n\t\t\t\tch = this.value.charCodeAt(pos + (++len));\n\t\t\t} while (Scanner.isVariableCharacter(ch) || Scanner.isDigitCharacter(ch));\n\n\t\t\tthis.pos += len;\n\t\t\treturn { type, pos, len };\n\t\t}\n\n\n\t\t// format\n\t\ttype = TokenType.Format;\n\t\tdo {\n\t\t\tlen += 1;\n\t\t\tch = this.value.charCodeAt(pos + len);\n\t\t} while (\n\t\t\t!isNaN(ch)\n\t\t\t&& typeof Scanner._table[ch] === 'undefined' // not static token\n\t\t\t&& !Scanner.isDigitCharacter(ch) // not number\n\t\t\t&& !Scanner.isVariableCharacter(ch) // not variable\n\t\t);\n\n\t\tthis.pos += len;\n\t\treturn { type, pos, len };\n\t}\n}\n\nexport abstract class Marker {\n\n\treadonly _markerBrand: any;\n\n\tpublic parent!: Marker;\n\tprotected _children: Marker[] = [];\n\n\tappendChild(child: Marker): this {\n\t\tif (child instanceof Text && this._children[this._children.length - 1] instanceof Text) {\n\t\t\t// this and previous child are text -> merge them\n\t\t\t(<Text>this._children[this._children.length - 1]).value += child.value;\n\t\t} else {\n\t\t\t// normal adoption of child\n\t\t\tchild.parent = this;\n\t\t\tthis._children.push(child);\n\t\t}\n\t\treturn this;\n\t}\n\n\treplace(child: Marker, others: Marker[]): void {\n\t\tconst { parent } = child;\n\t\tconst idx = parent.children.indexOf(child);\n\t\tconst newChildren = parent.children.slice(0);\n\t\tnewChildren.splice(idx, 1, ...others);\n\t\tparent._children = newChildren;\n\n\t\t(function _fixParent(children: Marker[], parent: Marker) {\n\t\t\tfor (const child of children) {\n\t\t\t\tchild.parent = parent;\n\t\t\t\t_fixParent(child.children, child);\n\t\t\t}\n\t\t})(others, parent);\n\t}\n\n\tget children(): Marker[] {\n\t\treturn this._children;\n\t}\n\n\tget snippet(): TextmateSnippet | undefined {\n\t\tlet candidate: Marker = this;\n\t\twhile (true) {\n\t\t\tif (!candidate) {\n\t\t\t\treturn undefined;\n\t\t\t}\n\t\t\tif (candidate instanceof TextmateSnippet) {\n\t\t\t\treturn candidate;\n\t\t\t}\n\t\t\tcandidate = candidate.parent;\n\t\t}\n\t}\n\n\ttoString(): string {\n\t\treturn this.children.reduce((prev, cur) => prev + cur.toString(), '');\n\t}\n\n\tabstract toTextmateString(): string;\n\n\tlen(): number {\n\t\treturn 0;\n\t}\n\n\tabstract clone(): Marker;\n}\n\nexport class Text extends Marker {\n\n\tstatic escape(value: string): string {\n\t\treturn value.replace(/\\$|}|\\\\/g, '\\\\$&');\n\t}\n\n\tconstructor(public value: string) {\n\t\tsuper();\n\t}\n\ttoString() {\n\t\treturn this.value;\n\t}\n\ttoTextmateString(): string {\n\t\treturn Text.escape(this.value);\n\t}\n\tlen(): number {\n\t\treturn this.value.length;\n\t}\n\tclone(): Text {\n\t\treturn new Text(this.value);\n\t}\n}\n\nexport abstract class TransformableMarker extends Marker {\n\tpublic transform?: Transform;\n}\n\nexport class Placeholder extends TransformableMarker {\n\tstatic compareByIndex(a: Placeholder, b: Placeholder): number {\n\t\tif (a.index === b.index) {\n\t\t\treturn 0;\n\t\t} else if (a.isFinalTabstop) {\n\t\t\treturn 1;\n\t\t} else if (b.isFinalTabstop) {\n\t\t\treturn -1;\n\t\t} else if (a.index < b.index) {\n\t\t\treturn -1;\n\t\t} else if (a.index > b.index) {\n\t\t\treturn 1;\n\t\t} else {\n\t\t\treturn 0;\n\t\t}\n\t}\n\n\tconstructor(public index: number) {\n\t\tsuper();\n\t}\n\n\tget isFinalTabstop() {\n\t\treturn this.index === 0;\n\t}\n\n\tget choice(): Choice | undefined {\n\t\treturn this._children.length === 1 && this._children[0] instanceof Choice\n\t\t\t? this._children[0] as Choice\n\t\t\t: undefined;\n\t}\n\n\ttoTextmateString(): string {\n\t\tlet transformString = '';\n\t\tif (this.transform) {\n\t\t\ttransformString = this.transform.toTextmateString();\n\t\t}\n\t\tif (this.children.length === 0 && !this.transform) {\n\t\t\treturn `\\$${this.index}`;\n\t\t} else if (this.children.length === 0) {\n\t\t\treturn `\\${${this.index}${transformString}}`;\n\t\t} else if (this.choice) {\n\t\t\treturn `\\${${this.index}|${this.choice.toTextmateString()}|${transformString}}`;\n\t\t} else {\n\t\t\treturn `\\${${this.index}:${this.children.map(child => child.toTextmateString()).join('')}${transformString}}`;\n\t\t}\n\t}\n\n\tclone(): Placeholder {\n\t\tlet ret = new Placeholder(this.index);\n\t\tif (this.transform) {\n\t\t\tret.transform = this.transform.clone();\n\t\t}\n\t\tret._children = this.children.map(child => child.clone());\n\t\treturn ret;\n\t}\n}\n\nexport class Choice extends Marker {\n\n\treadonly options: Text[] = [];\n\n\tappendChild(marker: Marker): this {\n\t\tif (marker instanceof Text) {\n\t\t\tmarker.parent = this;\n\t\t\tthis.options.push(marker);\n\t\t}\n\t\treturn this;\n\t}\n\n\ttoString() {\n\t\treturn this.options[0].value;\n\t}\n\n\ttoTextmateString(): string {\n\t\treturn this.options\n\t\t\t.map(option => option.value.replace(/\\||,/g, '\\\\$&'))\n\t\t\t.join(',');\n\t}\n\n\tlen(): number {\n\t\treturn this.options[0].len();\n\t}\n\n\tclone(): Choice {\n\t\tlet ret = new Choice();\n\t\tthis.options.forEach(ret.appendChild, ret);\n\t\treturn ret;\n\t}\n}\n\nexport class Transform extends Marker {\n\n\tregexp: RegExp = new RegExp('');\n\n\tresolve(value: string): string {\n\t\tconst _this = this;\n\t\tlet didMatch = false;\n\t\tlet ret = value.replace(this.regexp, function () {\n\t\t\tdidMatch = true;\n\t\t\treturn _this._replace(Array.prototype.slice.call(arguments, 0, -2));\n\t\t});\n\t\t// when the regex didn't match and when the transform has\n\t\t// else branches, then run those\n\t\tif (!didMatch && this._children.some(child => child instanceof FormatString && Boolean(child.elseValue))) {\n\t\t\tret = this._replace([]);\n\t\t}\n\t\treturn ret;\n\t}\n\n\tprivate _replace(groups: string[]): string {\n\t\tlet ret = '';\n\t\tfor (const marker of this._children) {\n\t\t\tif (marker instanceof FormatString) {\n\t\t\t\tlet value = groups[marker.index] || '';\n\t\t\t\tvalue = marker.resolve(value);\n\t\t\t\tret += value;\n\t\t\t} else {\n\t\t\t\tret += marker.toString();\n\t\t\t}\n\t\t}\n\t\treturn ret;\n\t}\n\n\ttoString(): string {\n\t\treturn '';\n\t}\n\n\ttoTextmateString(): string {\n\t\treturn `/${this.regexp.source}/${this.children.map(c => c.toTextmateString())}/${(this.regexp.ignoreCase ? 'i' : '') + (this.regexp.global ? 'g' : '')}`;\n\t}\n\n\tclone(): Transform {\n\t\tlet ret = new Transform();\n\t\tret.regexp = new RegExp(this.regexp.source, '' + (this.regexp.ignoreCase ? 'i' : '') + (this.regexp.global ? 'g' : ''));\n\t\tret._children = this.children.map(child => child.clone());\n\t\treturn ret;\n\t}\n\n}\n\nexport class FormatString extends Marker {\n\n\tconstructor(\n\t\treadonly index: number,\n\t\treadonly shorthandName?: string,\n\t\treadonly ifValue?: string,\n\t\treadonly elseValue?: string,\n\t) {\n\t\tsuper();\n\t}\n\n\tresolve(value?: string): string {\n\t\tif (this.shorthandName === 'upcase') {\n\t\t\treturn !value ? '' : value.toLocaleUpperCase();\n\t\t} else if (this.shorthandName === 'downcase') {\n\t\t\treturn !value ? '' : value.toLocaleLowerCase();\n\t\t} else if (this.shorthandName === 'capitalize') {\n\t\t\treturn !value ? '' : (value[0].toLocaleUpperCase() + value.substr(1));\n\t\t} else if (this.shorthandName === 'pascalcase') {\n\t\t\treturn !value ? '' : this._toPascalCase(value);\n\t\t} else if (this.shorthandName === 'camelcase') {\n\t\t\treturn !value ? '' : this._toCamelCase(value);\n\t\t} else if (Boolean(value) && typeof this.ifValue === 'string') {\n\t\t\treturn this.ifValue;\n\t\t} else if (!Boolean(value) && typeof this.elseValue === 'string') {\n\t\t\treturn this.elseValue;\n\t\t} else {\n\t\t\treturn value || '';\n\t\t}\n\t}\n\n\tprivate _toPascalCase(value: string): string {\n\t\tconst match = value.match(/[a-z0-9]+/gi);\n\t\tif (!match) {\n\t\t\treturn value;\n\t\t}\n\t\treturn match.map(word => {\n\t\t\treturn word.charAt(0).toUpperCase()\n\t\t\t\t+ word.substr(1).toLowerCase();\n\t\t})\n\t\t\t.join('');\n\t}\n\n\tprivate _toCamelCase(value: string): string {\n\t\tconst match = value.match(/[a-z0-9]+/gi);\n\t\tif (!match) {\n\t\t\treturn value;\n\t\t}\n\t\treturn match.map((word, index) => {\n\t\t\tif (index === 0) {\n\t\t\t\treturn word.toLowerCase();\n\t\t\t} else {\n\t\t\t\treturn word.charAt(0).toUpperCase()\n\t\t\t\t\t+ word.substr(1).toLowerCase();\n\t\t\t}\n\t\t})\n\t\t\t.join('');\n\t}\n\n\ttoTextmateString(): string {\n\t\tlet value = '${';\n\t\tvalue += this.index;\n\t\tif (this.shorthandName) {\n\t\t\tvalue += `:/${this.shorthandName}`;\n\n\t\t} else if (this.ifValue && this.elseValue) {\n\t\t\tvalue += `:?${this.ifValue}:${this.elseValue}`;\n\t\t} else if (this.ifValue) {\n\t\t\tvalue += `:+${this.ifValue}`;\n\t\t} else if (this.elseValue) {\n\t\t\tvalue += `:-${this.elseValue}`;\n\t\t}\n\t\tvalue += '}';\n\t\treturn value;\n\t}\n\n\tclone(): FormatString {\n\t\tlet ret = new FormatString(this.index, this.shorthandName, this.ifValue, this.elseValue);\n\t\treturn ret;\n\t}\n}\n\nexport class Variable extends TransformableMarker {\n\tpublic pos?: number\n\tpublic endPos?: number\n\n\tconstructor(public name: string, pos?: number, endPos?: number) {\n\t\tsuper();\n\t\tthis.pos = pos;\n\t\tthis.endPos = endPos;\n\t}\n\n\tasync resolve(resolver: VariableResolver): Promise<boolean> {\n\t\tlet value = await resolver.resolve(this);\n\t\tif (this.transform) {\n\t\t\tvalue = this.transform.resolve(value || '');\n\t\t}\n\t\tif (value !== undefined) {\n\t\t\tthis._children = [new Text(value)];\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\ttoTextmateString(): string {\n\t\tlet transformString = '';\n\t\tif (this.transform) {\n\t\t\ttransformString = this.transform.toTextmateString();\n\t\t}\n\t\tif (this.children.length === 0) {\n\t\t\treturn `\\${${this.name}${transformString}}`;\n\t\t} else {\n\t\t\treturn `\\${${this.name}:${this.children.map(child => child.toTextmateString()).join('')}${transformString}}`;\n\t\t}\n\t}\n\n\tclone(): Variable {\n\t\tconst ret = new Variable(this.name, this.pos, this.endPos);\n\t\tif (this.transform) {\n\t\t\tret.transform = this.transform.clone();\n\t\t}\n\t\tret._children = this.children.map(child => child.clone());\n\t\treturn ret;\n\t}\n}\n\nexport interface VariableResolver {\n\tresolve(variable: Variable): Promise<string | undefined>;\n}\n\nasync function asyncWalk(marker: Marker[], visitor: (marker: Marker) => Promise<boolean>): Promise<void> {\n\tconst stack = [...marker];\n\twhile (stack.length > 0) {\n\t\tconst marker = stack.shift()!;\n\t\tconst recurse = await visitor(marker);\n\t\tif (!recurse) {\n\t\t\tbreak;\n\t\t}\n\t\tstack.unshift(...marker.children);\n\t}\n}\n\nfunction walk(marker: Marker[], visitor: (marker: Marker) => boolean): void {\n\tconst stack = [...marker];\n\twhile (stack.length > 0) {\n\t\tconst marker = stack.shift()!;\n\t\tconst recurse = visitor(marker);\n\t\tif (!recurse) {\n\t\t\tbreak;\n\t\t}\n\t\tstack.unshift(...marker.children);\n\t}\n}\n\nexport class TextmateSnippet extends Marker {\n\n\tprivate value: string\n\tprivate _placeholders?: { all: Placeholder[], last?: Placeholder };\n\n\tconstructor(value: string) {\n\t\tsuper();\n\t\tthis.value = value;\n\t}\n\n\tvariables(): Variable[] {\n\t\tconst variables: Variable[] = [];\n\t\tthis.walk(marker => {\n\t\t\tif (marker instanceof Variable) {\n\t\t\t\tvariables.push(marker as Variable);\n\t\t\t}\n\t\t\treturn true;\n\t\t});\n\t\treturn variables;\n\t}\n\n\tsnippetTextWithVariablesSubstituted(variableNames?: Set<string>): string {\n\t\tconst resolvedVariables: Variable[] = [];\n\t\tthis.walk(marker => {\n\t\t\tif (marker instanceof Variable && (!variableNames || variableNames.has(marker.name))) {\n\t\t\t\tresolvedVariables.push(marker as Variable);\n\t\t\t}\n\t\t\treturn true;\n\t\t});\n\n\t\tlet result = '';\n\n\t\tlet i = 0;\n\t\tresolvedVariables.forEach(variable => {\n\t\t\tresult += this.value.substring(i, variable.pos);\n\t\t\tresult += variable.toString();\n\t\t\ti = variable.endPos;\n\t\t});\n\t\tresult += this.value.substring(i);\n\n    return result;\n\t}\n\n\tget placeholderInfo() {\n\t\tif (!this._placeholders) {\n\t\t\t// fill in placeholders\n\t\t\tlet all: Placeholder[] = [];\n\t\t\tlet last: Placeholder | undefined;\n\t\t\tthis.walk(function (candidate) {\n\t\t\t\tif (candidate instanceof Placeholder) {\n\t\t\t\t\tall.push(candidate);\n\t\t\t\t\tlast = !last || last.index < candidate.index ? candidate : last;\n\t\t\t\t}\n\t\t\t\treturn true;\n\t\t\t});\n\t\t\tthis._placeholders = { all, last };\n\t\t}\n\t\treturn this._placeholders;\n\t}\n\n\tget placeholders(): Placeholder[] {\n\t\tconst { all } = this.placeholderInfo;\n\t\treturn all;\n\t}\n\n\toffset(marker: Marker): number {\n\t\tlet pos = 0;\n\t\tlet found = false;\n\t\tthis.walk(candidate => {\n\t\t\tif (candidate === marker) {\n\t\t\t\tfound = true;\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tpos += candidate.len();\n\t\t\treturn true;\n\t\t});\n\n\t\tif (!found) {\n\t\t\treturn -1;\n\t\t}\n\t\treturn pos;\n\t}\n\n\tfullLen(marker: Marker): number {\n\t\tlet ret = 0;\n\t\twalk([marker], marker => {\n\t\t\tret += marker.len();\n\t\t\treturn true;\n\t\t});\n\t\treturn ret;\n\t}\n\n\tenclosingPlaceholders(placeholder: Placeholder): Placeholder[] {\n\t\tlet ret: Placeholder[] = [];\n\t\tlet { parent } = placeholder;\n\t\twhile (parent) {\n\t\t\tif (parent instanceof Placeholder) {\n\t\t\t\tret.push(parent);\n\t\t\t}\n\t\t\tparent = parent.parent;\n\t\t}\n\t\treturn ret;\n\t}\n\n\tasync resolveVariables(resolver: VariableResolver, variableNames?: Set<string>): Promise<this> {\n\t\tawait this.asyncWalk(async candidate => {\n\t\t\tif (candidate instanceof Variable && (!variableNames || variableNames.has(candidate.name))) {\n\t\t\t\tif (await candidate.resolve(resolver)) {\n\t\t\t\t\tthis._placeholders = undefined;\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t});\n\t\treturn this;\n\t}\n\n\tappendChild(child: Marker) {\n\t\tthis._placeholders = undefined;\n\t\treturn super.appendChild(child);\n\t}\n\n\treplace(child: Marker, others: Marker[]): void {\n\t\tthis._placeholders = undefined;\n\t\treturn super.replace(child, others);\n\t}\n\n\ttoTextmateString(): string {\n\t\treturn this.children.reduce((prev, cur) => prev + cur.toTextmateString(), '');\n\t}\n\n\tclone(): TextmateSnippet {\n\t\tlet ret = new TextmateSnippet(this.value);\n\t\tthis._children = this.children.map(child => child.clone());\n\t\treturn ret;\n\t}\n\n\tasync asyncWalk(visitor: (marker: Marker) => Promise<boolean>): Promise<void> {\n\t\tawait asyncWalk(this.children, visitor);\n\t}\n\n\twalk(visitor: (marker: Marker) => boolean): void {\n\t\twalk(this.children, visitor);\n\t}\n}\n\nexport class SnippetParser {\n\n\tstatic escape(value: string): string {\n\t\treturn value.replace(/\\$|}|\\\\/g, '\\\\$&');\n\t}\n\n\tstatic guessNeedsClipboard(template: string): boolean {\n\t\treturn /\\${?CLIPBOARD/.test(template);\n\t}\n\n\tprivate _scanner: Scanner = new Scanner();\n\tprivate _token: Token = { type: TokenType.EOF, pos: 0, len: 0 };\n\n\ttext(value: string): string {\n\t\treturn this.parse(value).toString();\n\t}\n\n\tparse(value: string, insertFinalTabstop?: boolean, enforceFinalTabstop?: boolean): TextmateSnippet {\n\n\t\tthis._scanner.text(value);\n\t\tthis._token = this._scanner.next();\n\n\t\tconst snippet = new TextmateSnippet(value);\n\t\twhile (this._parse(snippet)) {\n\t\t\t// nothing\n\t\t}\n\n\t\t// fill in values for placeholders. the first placeholder of an index\n\t\t// that has a value defines the value for all placeholders with that index\n\t\tconst placeholderDefaultValues = new Map<number, Marker[] | undefined>();\n\t\tconst incompletePlaceholders: Placeholder[] = [];\n\t\tlet placeholderCount = 0;\n\t\tsnippet.walk(marker => {\n\t\t\tif (marker instanceof Placeholder) {\n\t\t\t\tplaceholderCount += 1;\n\t\t\t\tif (marker.isFinalTabstop) {\n\t\t\t\t\tplaceholderDefaultValues.set(0, undefined);\n\t\t\t\t} else if (!placeholderDefaultValues.has(marker.index) && marker.children.length > 0) {\n\t\t\t\t\tplaceholderDefaultValues.set(marker.index, marker.children);\n\t\t\t\t} else {\n\t\t\t\t\tincompletePlaceholders.push(marker);\n\t\t\t\t}\n\t\t\t}\n\t\t\treturn true;\n\t\t});\n\t\tfor (const placeholder of incompletePlaceholders) {\n\t\t\tconst defaultValues = placeholderDefaultValues.get(placeholder.index);\n\t\t\tif (defaultValues) {\n\t\t\t\tconst clone = new Placeholder(placeholder.index);\n\t\t\t\tclone.transform = placeholder.transform;\n\t\t\t\tfor (const child of defaultValues) {\n\t\t\t\t\tclone.appendChild(child.clone());\n\t\t\t\t}\n\t\t\t\tsnippet.replace(placeholder, [clone]);\n\t\t\t}\n\t\t}\n\n\t\tif (!enforceFinalTabstop) {\n\t\t\tenforceFinalTabstop = placeholderCount > 0 && insertFinalTabstop;\n\t\t}\n\n\t\tif (!placeholderDefaultValues.has(0) && enforceFinalTabstop) {\n\t\t\t// the snippet uses placeholders but has no\n\t\t\t// final tabstop defined -> insert at the end\n\t\t\tsnippet.appendChild(new Placeholder(0));\n\t\t}\n\n\t\treturn snippet;\n\t}\n\n\tprivate _accept(type?: TokenType): boolean;\n\tprivate _accept(type: TokenType | undefined, value: true): string;\n\tprivate _accept(type: TokenType, value?: boolean): boolean | string {\n\t\tif (type === undefined || this._token.type === type) {\n\t\t\tlet ret = !value ? true : this._scanner.tokenText(this._token);\n\t\t\tthis._token = this._scanner.next();\n\t\t\treturn ret;\n\t\t}\n\t\treturn false;\n\t}\n\n\tprivate _backTo(token: Token): false {\n\t\tthis._scanner.pos = token.pos + token.len;\n\t\tthis._token = token;\n\t\treturn false;\n\t}\n\n\tprivate _until(type: TokenType): false | string {\n\t\tconst start = this._token;\n\t\twhile (this._token.type !== type) {\n\t\t\tif (this._token.type === TokenType.EOF) {\n\t\t\t\treturn false;\n\t\t\t} else if (this._token.type === TokenType.Backslash) {\n\t\t\t\tconst nextToken = this._scanner.next();\n\t\t\t\tif (nextToken.type !== TokenType.Dollar\n\t\t\t\t\t&& nextToken.type !== TokenType.CurlyClose\n\t\t\t\t\t&& nextToken.type !== TokenType.Backslash) {\n\t\t\t\t\treturn false;\n\t\t\t\t}\n\t\t\t}\n\t\t\tthis._token = this._scanner.next();\n\t\t}\n\t\tconst value = this._scanner.value.substring(start.pos, this._token.pos).replace(/\\\\(\\$|}|\\\\)/g, '$1');\n\t\tthis._token = this._scanner.next();\n\t\treturn value;\n\t}\n\n\tprivate _parse(marker: Marker): boolean {\n\t\treturn this._parseEscaped(marker)\n\t\t\t|| this._parseTabstopOrVariableName(marker)\n\t\t\t|| this._parseComplexPlaceholder(marker)\n\t\t\t|| this._parseComplexVariable(marker)\n\t\t\t|| this._parseAnything(marker);\n\t}\n\n\t// \\$, \\\\, \\} -> just text\n\tprivate _parseEscaped(marker: Marker): boolean {\n\t\tlet value: string;\n\t\tif (value = this._accept(TokenType.Backslash, true)) {\n\t\t\t// saw a backslash, append escaped token or that backslash\n\t\t\tvalue = this._accept(TokenType.Dollar, true)\n\t\t\t\t|| this._accept(TokenType.CurlyClose, true)\n\t\t\t\t|| this._accept(TokenType.Backslash, true)\n\t\t\t\t|| value;\n\n\t\t\tmarker.appendChild(new Text(value));\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n\n\t// $foo -> variable, $1 -> tabstop\n\tprivate _parseTabstopOrVariableName(parent: Marker): boolean {\n\t\tlet value: string;\n\t\tconst token = this._token;\n\t\tconst match = this._accept(TokenType.Dollar)\n\t\t\t&& (value = this._accept(TokenType.VariableName, true) || this._accept(TokenType.Int, true));\n\n\t\tif (!match) {\n\t\t\treturn this._backTo(token);\n\t\t}\n\n\t\tparent.appendChild(/^\\d+$/.test(value!)\n\t\t\t? new Placeholder(Number(value!))\n\t\t\t: new Variable(value!, token.pos, this._scanner.pos - this._token.len)\n\t\t);\n\t\treturn true;\n\t}\n\n\t// ${1:<children>}, ${1} -> placeholder\n\tprivate _parseComplexPlaceholder(parent: Marker): boolean {\n\t\tlet index: string;\n\t\tconst token = this._token;\n\t\tconst match = this._accept(TokenType.Dollar)\n\t\t\t&& this._accept(TokenType.CurlyOpen)\n\t\t\t&& (index = this._accept(TokenType.Int, true));\n\n\t\tif (!match) {\n\t\t\treturn this._backTo(token);\n\t\t}\n\n\t\tconst placeholder = new Placeholder(Number(index!));\n\n\t\tif (this._accept(TokenType.Colon)) {\n\t\t\t// ${1:<children>}\n\t\t\twhile (true) {\n\n\t\t\t\t// ...} -> done\n\t\t\t\tif (this._accept(TokenType.CurlyClose)) {\n\t\t\t\t\tparent.appendChild(placeholder);\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\n\t\t\t\tif (this._parse(placeholder)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// fallback\n\t\t\t\tparent.appendChild(new Text('${' + index! + ':'));\n\t\t\t\tplaceholder.children.forEach(parent.appendChild, parent);\n\t\t\t\treturn true;\n\t\t\t}\n\t\t} else if (placeholder.index > 0 && this._accept(TokenType.Pipe)) {\n\t\t\t// ${1|one,two,three|}\n\t\t\tconst choice = new Choice();\n\n\t\t\twhile (true) {\n\t\t\t\tif (this._parseChoiceElement(choice)) {\n\n\t\t\t\t\tif (this._accept(TokenType.Comma)) {\n\t\t\t\t\t\t// opt, -> more\n\t\t\t\t\t\tcontinue;\n\t\t\t\t\t}\n\n\t\t\t\t\tif (this._accept(TokenType.Pipe)) {\n\t\t\t\t\t\tplaceholder.appendChild(choice);\n\t\t\t\t\t\tif (this._accept(TokenType.CurlyClose)) {\n\t\t\t\t\t\t\t// ..|} -> done\n\t\t\t\t\t\t\tparent.appendChild(placeholder);\n\t\t\t\t\t\t\treturn true;\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t}\n\n\t\t\t\tthis._backTo(token);\n\t\t\t\treturn false;\n\t\t\t}\n\n\t\t} else if (this._accept(TokenType.Forwardslash)) {\n\t\t\t// ${1/<regex>/<format>/<options>}\n\t\t\tif (this._parseTransform(placeholder)) {\n\t\t\t\tparent.appendChild(placeholder);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tthis._backTo(token);\n\t\t\treturn false;\n\n\t\t} else if (this._accept(TokenType.CurlyClose)) {\n\t\t\t// ${1}\n\t\t\tparent.appendChild(placeholder);\n\t\t\treturn true;\n\n\t\t} else {\n\t\t\t// ${1 <- missing curly or colon\n\t\t\treturn this._backTo(token);\n\t\t}\n\t}\n\n\tprivate _parseChoiceElement(parent: Choice): boolean {\n\t\tconst token = this._token;\n\t\tconst values: string[] = [];\n\n\t\twhile (true) {\n\t\t\tif (this._token.type === TokenType.Comma || this._token.type === TokenType.Pipe) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tlet value: string;\n\t\t\tif (value = this._accept(TokenType.Backslash, true)) {\n\t\t\t\t// \\, \\|, or \\\\\n\t\t\t\tvalue = this._accept(TokenType.Comma, true)\n\t\t\t\t\t|| this._accept(TokenType.Pipe, true)\n\t\t\t\t\t|| this._accept(TokenType.Backslash, true)\n\t\t\t\t\t|| value;\n\t\t\t} else {\n\t\t\t\tvalue = this._accept(undefined, true);\n\t\t\t}\n\t\t\tif (!value) {\n\t\t\t\t// EOF\n\t\t\t\tthis._backTo(token);\n\t\t\t\treturn false;\n\t\t\t}\n\t\t\tvalues.push(value);\n\t\t}\n\n\t\tif (values.length === 0) {\n\t\t\tthis._backTo(token);\n\t\t\treturn false;\n\t\t}\n\n\t\tparent.appendChild(new Text(values.join('')));\n\t\treturn true;\n\t}\n\n\t// ${foo:<children>}, ${foo} -> variable\n\tprivate _parseComplexVariable(parent: Marker): boolean {\n\t\tlet name: string;\n\t\tconst token = this._token;\n\t\tconst match = this._accept(TokenType.Dollar)\n\t\t\t&& this._accept(TokenType.CurlyOpen)\n\t\t\t&& (name = this._accept(TokenType.VariableName, true));\n\n\t\tif (!match) {\n\t\t\treturn this._backTo(token);\n\t\t}\n\n\t\tconst variable = new Variable(name!, token.pos);\n\n\t\tif (this._accept(TokenType.Colon)) {\n\t\t\t// ${foo:<children>}\n\t\t\twhile (true) {\n\n\t\t\t\t// ...} -> done\n\t\t\t\tif (this._accept(TokenType.CurlyClose)) {\n\t\t\t\t\tvariable.endPos = this._scanner.pos - this._token.len\n\t\t\t\t\tparent.appendChild(variable);\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\n\t\t\t\tif (this._parse(variable)) {\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\t// fallback\n\t\t\t\tparent.appendChild(new Text('${' + name! + ':'));\n\t\t\t\tvariable.children.forEach(parent.appendChild, parent);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t} else if (this._accept(TokenType.Forwardslash)) {\n\t\t\t// ${foo/<regex>/<format>/<options>}\n\t\t\tif (this._parseTransform(variable)) {\n\t\t\t\tvariable.endPos = this._scanner.pos - this._token.len\n\t\t\t\tparent.appendChild(variable);\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t\tthis._backTo(token);\n\t\t\treturn false;\n\n\t\t} else if (this._accept(TokenType.CurlyClose)) {\n\t\t\t// ${foo}\n\t\t\tvariable.endPos = this._scanner.pos - this._token.len\n\t\t\tparent.appendChild(variable);\n\t\t\treturn true;\n\n\t\t} else {\n\t\t\t// ${foo <- missing curly or colon\n\t\t\treturn this._backTo(token);\n\t\t}\n\t}\n\n\tprivate _parseTransform(parent: TransformableMarker): boolean {\n\t\t// ...<regex>/<format>/<options>}\n\n\t\tlet transform = new Transform();\n\t\tlet regexValue = '';\n\t\tlet regexOptions = '';\n\n\t\t// (1) /regex\n\t\twhile (true) {\n\t\t\tif (this._accept(TokenType.Forwardslash)) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tlet escaped: string;\n\t\t\tif (escaped = this._accept(TokenType.Backslash, true)) {\n\t\t\t\tescaped = this._accept(TokenType.Forwardslash, true) || escaped;\n\t\t\t\tregexValue += escaped;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (this._token.type !== TokenType.EOF) {\n\t\t\t\tregexValue += this._accept(undefined, true);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\t// (2) /format\n\t\twhile (true) {\n\t\t\tif (this._accept(TokenType.Forwardslash)) {\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tlet escaped: string;\n\t\t\tif (escaped = this._accept(TokenType.Backslash, true)) {\n\t\t\t\tescaped = this._accept(TokenType.Backslash, true) || this._accept(TokenType.Forwardslash, true) || escaped;\n\t\t\t\ttransform.appendChild(new Text(escaped));\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif (this._parseFormatString(transform) || this._parseAnything(transform)) {\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\t// (3) /option\n\t\twhile (true) {\n\t\t\tif (this._accept(TokenType.CurlyClose)) {\n\t\t\t\tbreak;\n\t\t\t}\n\t\t\tif (this._token.type !== TokenType.EOF) {\n\t\t\t\tregexOptions += this._accept(undefined, true);\n\t\t\t\tcontinue;\n\t\t\t}\n\t\t\treturn false;\n\t\t}\n\n\t\ttry {\n\t\t\ttransform.regexp = new RegExp(regexValue, regexOptions);\n\t\t} catch (e) {\n\t\t\t// invalid regexp\n\t\t\treturn false;\n\t\t}\n\n\t\tparent.transform = transform;\n\t\treturn true;\n\t}\n\n\tprivate _parseFormatString(parent: Transform): boolean {\n\n\t\tconst token = this._token;\n\t\tif (!this._accept(TokenType.Dollar)) {\n\t\t\treturn false;\n\t\t}\n\n\t\tlet complex = false;\n\t\tif (this._accept(TokenType.CurlyOpen)) {\n\t\t\tcomplex = true;\n\t\t}\n\n\t\tlet index = this._accept(TokenType.Int, true);\n\n\t\tif (!index) {\n\t\t\tthis._backTo(token);\n\t\t\treturn false;\n\n\t\t} else if (!complex) {\n\t\t\t// $1\n\t\t\tparent.appendChild(new FormatString(Number(index)));\n\t\t\treturn true;\n\n\t\t} else if (this._accept(TokenType.CurlyClose)) {\n\t\t\t// ${1}\n\t\t\tparent.appendChild(new FormatString(Number(index)));\n\t\t\treturn true;\n\n\t\t} else if (!this._accept(TokenType.Colon)) {\n\t\t\tthis._backTo(token);\n\t\t\treturn false;\n\t\t}\n\n\t\tif (this._accept(TokenType.Forwardslash)) {\n\t\t\t// ${1:/upcase}\n\t\t\tlet shorthand = this._accept(TokenType.VariableName, true);\n\t\t\tif (!shorthand || !this._accept(TokenType.CurlyClose)) {\n\t\t\t\tthis._backTo(token);\n\t\t\t\treturn false;\n\t\t\t} else {\n\t\t\t\tparent.appendChild(new FormatString(Number(index), shorthand));\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t} else if (this._accept(TokenType.Plus)) {\n\t\t\t// ${1:+<if>}\n\t\t\tlet ifValue = this._until(TokenType.CurlyClose);\n\t\t\tif (ifValue) {\n\t\t\t\tparent.appendChild(new FormatString(Number(index), undefined, ifValue, undefined));\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t} else if (this._accept(TokenType.Dash)) {\n\t\t\t// ${2:-<else>}\n\t\t\tlet elseValue = this._until(TokenType.CurlyClose);\n\t\t\tif (elseValue) {\n\t\t\t\tparent.appendChild(new FormatString(Number(index), undefined, undefined, elseValue));\n\t\t\t\treturn true;\n\t\t\t}\n\n\t\t} else if (this._accept(TokenType.QuestionMark)) {\n\t\t\t// ${2:?<if>:<else>}\n\t\t\tlet ifValue = this._until(TokenType.Colon);\n\t\t\tif (ifValue) {\n\t\t\t\tlet elseValue = this._until(TokenType.CurlyClose);\n\t\t\t\tif (elseValue) {\n\t\t\t\t\tparent.appendChild(new FormatString(Number(index), undefined, ifValue, elseValue));\n\t\t\t\t\treturn true;\n\t\t\t\t}\n\t\t\t}\n\n\t\t} else {\n\t\t\t// ${1:<else>}\n\t\t\tlet elseValue = this._until(TokenType.CurlyClose);\n\t\t\tif (elseValue) {\n\t\t\t\tparent.appendChild(new FormatString(Number(index), undefined, undefined, elseValue));\n\t\t\t\treturn true;\n\t\t\t}\n\t\t}\n\n\t\tthis._backTo(token);\n\t\treturn false;\n\t}\n\n\tprivate _parseAnything(marker: Marker): boolean {\n\t\tif (this._token.type !== TokenType.EOF) {\n\t\t\tmarker.appendChild(new Text(this._scanner.tokenText(this._token)));\n\t\t\tthis._accept(undefined);\n\t\t\treturn true;\n\t\t}\n\t\treturn false;\n\t}\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/convert-links-format.test.ts",
    "content": "import { convertLinkFormat } from '.';\nimport { TEST_DATA_DIR } from '../../test/test-utils';\nimport { MarkdownResourceProvider } from '../services/markdown-provider';\nimport { Resource } from '../model/note';\nimport { FoamWorkspace } from '../model/workspace';\nimport { Logger } from '../utils/log';\nimport fs from 'fs';\nimport { URI } from '../model/uri';\nimport { createMarkdownParser } from '../services/markdown-parser';\nimport { FileDataStore } from '../../test/test-datastore';\n\nLogger.setLevel('error');\n\ndescribe('generateStdMdLink', () => {\n  let _workspace: FoamWorkspace;\n  // TODO slug must be reserved for actual slugs, not file names\n  const findBySlug = (slug: string): Resource => {\n    return _workspace\n      .list()\n      .find(res => res.uri.getName() === slug) as Resource;\n  };\n\n  beforeAll(async () => {\n    /** Use fs for reading files in units where vscode.workspace is unavailable */\n    const readFile = async (uri: URI) =>\n      (await fs.promises.readFile(uri.toFsPath())).toString();\n    const dataStore = new FileDataStore(\n      readFile,\n      TEST_DATA_DIR.joinPath('__scaffold__').toFsPath()\n    );\n    const parser = createMarkdownParser();\n    const mdProvider = new MarkdownResourceProvider(dataStore, parser);\n    _workspace = await FoamWorkspace.fromProviders([], [mdProvider], dataStore);\n  });\n\n  it('initialised test graph correctly', () => {\n    expect(_workspace.list().length).toEqual(11);\n  });\n\n  it('can generate markdown links correctly', async () => {\n    const note = findBySlug('file-with-different-link-formats');\n    const actual = note.links\n      .filter(link => link.type === 'wikilink')\n      .map(link => convertLinkFormat(link, 'link', _workspace, note));\n    const expected: string[] = [\n      '[first-document](first-document.md)',\n      '[second-document](second-document.md)',\n      '[[non-exist-file]]',\n      '[#one section](<file-with-different-link-formats.md#one section>)',\n      '[another name](<file-with-different-link-formats.md#one section>)',\n      '[an alias](first-document.md)',\n      '[first-document](first-document.md)',\n    ];\n    expect(actual.length).toEqual(expected.length);\n    actual.forEach((LinkReplace, index) => {\n      expect(LinkReplace.newText).toEqual(expected[index]);\n    });\n  });\n\n  it('can generate wikilinks correctly', async () => {\n    const note = findBySlug('file-with-different-link-formats');\n    const actual = note.links\n      .filter(link => link.type === 'link')\n      .map(link => convertLinkFormat(link, 'wikilink', _workspace, note));\n    const expected: string[] = ['[[first-document|file]]'];\n    expect(actual.length).toEqual(expected.length);\n    actual.forEach((LinkReplace, index) => {\n      expect(LinkReplace.newText).toEqual(expected[index]);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/convert-links-format.ts",
    "content": "import { Resource, ResourceLink } from '../model/note';\nimport { URI } from '../model/uri';\nimport { FoamWorkspace } from '../model/workspace';\nimport { isNone } from '../utils';\nimport { MarkdownLink } from '../services/markdown-link';\nimport { TextEdit } from '../services/text-edit';\n\n/**\n * convert a link based on its workspace and the note containing it.\n * According to targetFormat parameter to decide output format. If link.type === targetFormat, then it simply copy\n * the rawText into LinkReplace. Therefore, it's recommended to filter before conversion.\n * If targetFormat isn't supported, or the target resource pointed by link cannot be found, the function will throw\n * exception.\n * @param link\n * @param targetFormat 'wikilink' | 'link'\n * @param workspace\n * @param note\n * @returns LinkReplace { newText: string; range: Range; }\n */\nexport function convertLinkFormat(\n  link: ResourceLink,\n  targetFormat: 'wikilink' | 'link',\n  workspace: FoamWorkspace,\n  note: Resource | URI\n): TextEdit {\n  const resource = note instanceof URI ? workspace.find(note) : note;\n  const targetUri = workspace.resolveLink(resource, link);\n  /* If it's already the target format or a placeholder, no transformation happens */\n  if (link.type === targetFormat || targetUri.scheme === 'placeholder') {\n    return {\n      newText: link.rawText,\n      range: link.range,\n    };\n  }\n\n  let { target, section, alias } = MarkdownLink.analyzeLink(link);\n  let sectionDivider = section ? '#' : '';\n\n  if (isNone(targetUri)) {\n    throw new Error(\n      `Unexpected state: link to: \"${link.rawText}\" is not resolvable`\n    );\n  }\n\n  const targetRes = workspace.find(targetUri);\n  let relativeUri = targetRes.uri.relativeTo(resource.uri.getDirectory());\n\n  if (targetFormat === 'wikilink') {\n    return MarkdownLink.createUpdateLinkEdit(link, {\n      target: workspace.getIdentifier(relativeUri),\n      type: 'wikilink',\n    });\n  }\n\n  if (targetFormat === 'link') {\n    /* if alias is empty, construct one as target#section */\n    if (alias === '') {\n      /* in page anchor have no filename */\n      if (relativeUri.getBasename() === resource.uri.getBasename()) {\n        target = '';\n      }\n      alias = `${target}${sectionDivider}${section}`;\n    }\n\n    /* if it's originally an embedded note, the markdown link shouldn't be embedded */\n    const isEmbed = targetRes.type === 'image' ? link.isEmbed : false;\n\n    return MarkdownLink.createUpdateLinkEdit(link, {\n      alias: alias,\n      target: relativeUri.path,\n      isEmbed: isEmbed,\n      type: 'link',\n    });\n  }\n  throw new Error(\n    `Unexpected state: targetFormat: ${targetFormat} is not supported`\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/generate-headings.test.ts",
    "content": "import { generateHeading } from '.';\nimport { readFileFromFs, TEST_DATA_DIR } from '../../test/test-utils';\nimport { MarkdownResourceProvider } from '../services/markdown-provider';\nimport { Resource } from '../model/note';\nimport { Range } from '../model/range';\nimport { FoamWorkspace } from '../model/workspace';\nimport { Logger } from '../utils/log';\nimport detectNewline from 'detect-newline';\nimport { createMarkdownParser } from '../services/markdown-parser';\nimport { FileDataStore } from '../../test/test-datastore';\n\nLogger.setLevel('error');\n\ndescribe('generateHeadings', () => {\n  let _workspace: FoamWorkspace;\n  const findBySlug = (slug: string): Resource => {\n    return _workspace\n      .list()\n      .find(res => res.uri.getName() === slug) as Resource;\n  };\n\n  beforeAll(async () => {\n    const dataStore = new FileDataStore(\n      readFileFromFs,\n      TEST_DATA_DIR.joinPath('__scaffold__').toFsPath()\n    );\n    const parser = createMarkdownParser();\n    const mdProvider = new MarkdownResourceProvider(dataStore, parser);\n    _workspace = await FoamWorkspace.fromProviders([], [mdProvider], dataStore);\n  });\n\n  it.skip('should add heading to a file that does not have them', async () => {\n    const note = findBySlug('file-without-title');\n    const expected = {\n      newText: `# File without Title\n\n`,\n      range: Range.create(0, 0, 0, 0),\n    };\n\n    const noteText = await _workspace.readAsMarkdown(note.uri);\n    const noteEol = detectNewline(noteText);\n    const actual = await generateHeading(note, noteText, noteEol);\n\n    expect(actual!.range.start).toEqual(expected.range.start);\n    expect(actual!.range.end).toEqual(expected.range.end);\n    expect(actual!.newText).toEqual(expected.newText);\n  });\n\n  it('should not cause any changes to a file that has a heading', async () => {\n    const note = findBySlug('index');\n    const noteText = await _workspace.readAsMarkdown(note.uri);\n    const noteEol = detectNewline(noteText);\n    const actual = await generateHeading(note, noteText, noteEol);\n\n    expect(actual).toBeNull();\n  });\n\n  it.skip('should generate heading when the file only contains frontmatter', async () => {\n    const note = findBySlug('file-with-only-frontmatter');\n\n    const expected = {\n      newText: '\\n# File with only Frontmatter\\n\\n',\n      range: Range.create(3, 0, 3, 0),\n    };\n\n    const noteText = await _workspace.readAsMarkdown(note.uri);\n    const noteEol = detectNewline(noteText);\n    const actual = await generateHeading(note, noteText, noteEol);\n\n    expect(actual!.range.start).toEqual(expected.range.start);\n    expect(actual!.range.end).toEqual(expected.range.end);\n    expect(actual!.newText).toEqual(expected.newText);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/generate-headings.ts",
    "content": "import matter from 'gray-matter';\nimport { Resource } from '../model/note';\nimport { Range } from '../model/range';\nimport { TextEdit } from '../services/text-edit';\nimport { getHeadingFromFileName } from '../utils';\n\nexport const generateHeading = async (\n  note: Resource,\n  noteText: string,\n  eol: string\n): Promise<TextEdit | null> => {\n  if (!note) {\n    return null;\n  }\n\n  // TODO now the note.title defaults to file name at parsing time, so this check\n  // doesn't work anymore. Decide:\n  // - whether do we actually want to continue generate the headings\n  // - whether it should be under a config option\n  // A possible approach would be around having a `sections` field in the note, and inspect\n  // it to see if there is an h1 title. Alternatively parse directly the markdown in this function.\n  if (note.title) {\n    return null;\n  }\n\n  const fm = matter(noteText);\n  const contentStartLine = fm.matter.split(eol).length;\n  const frontmatterExists = contentStartLine > 0;\n\n  let newLineExistsAfterFrontmatter = false;\n  if (frontmatterExists) {\n    const lines = noteText.split(eol);\n    const index = contentStartLine - 1;\n    const line = lines[index];\n    newLineExistsAfterFrontmatter = line === '';\n  }\n\n  const paddingStart = frontmatterExists ? eol : '';\n  const paddingEnd = newLineExistsAfterFrontmatter ? eol : `${eol}${eol}`;\n\n  return {\n    newText: `${paddingStart}# ${getHeadingFromFileName(\n      note.uri.getName()\n    )}${paddingEnd}`,\n    range: Range.create(contentStartLine, 0, contentStartLine, 0),\n  };\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/generate-link-references.test.ts",
    "content": "import { generateLinkReferences } from '.';\nimport { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport { EOL } from 'os';\nimport { createMarkdownParser } from '../services/markdown-parser';\nimport { TextEdit } from '../services/text-edit';\n\nLogger.setLevel('error');\n\n/**\n * Will adjust a text line separator to match\n * what is used by the note\n * Necessary when running tests on windows\n *\n * @param note the note we are adjusting for\n * @param text starting text, using a \\n line separator\n */\nfunction textForNote(text: string): string {\n  const eol = EOL;\n  return text.split('\\n').join(eol);\n}\n\ndescribe('generateLinkReferences', () => {\n  const parser = createMarkdownParser();\n\n  interface TestCase {\n    case: string;\n    input: string;\n    expected: string;\n  }\n\n  const testCases: TestCase[] = [\n    {\n      case: 'should add link references for wikilinks present in note',\n      input: `\n# Index\n[[doc1]] [[doc2]] [[file-without-title]]\n`,\n      expected: `\n# Index\n[[doc1]] [[doc2]] [[file-without-title]]\n\n[doc1]: doc1 \"First\"\n[doc2]: doc2 \"Second\"\n[file-without-title]: file-without-title \"file-without-title\"\n`,\n    },\n    {\n      case: '#1558 - should keep a blank line before link references',\n      input: `\n# Test\n\n[[doc1]]\n\n[[doc2]]\n\n\n\n`,\n      expected: `\n# Test\n\n[[doc1]]\n\n[[doc2]]\n\n[doc1]: doc1 \"First\"\n[doc2]: doc2 \"Second\"\n`,\n    },\n    {\n      case: 'should remove obsolete link definitions',\n      input: `\n# Document\nSome content here.\n[doc1]: doc1 \"First\"\n`,\n      expected: `\n# Document\nSome content here.\n\n`,\n    },\n    {\n      case: 'should add and remove link definitions as needed',\n      input: `\n# First Document\n\nHere's some [unrelated] content.\n\n[unrelated]: http://unrelated.com 'This link should not be changed'\n\n[[file-without-title]]\n\n[doc2]: doc2 'Second Document'\n`,\n      expected: `\n# First Document\n\nHere's some [unrelated] content.\n\n[unrelated]: http://unrelated.com 'This link should not be changed'\n\n[[file-without-title]]\n\n\n\n[file-without-title]: file-without-title \"file-without-title\"\n`,\n    },\n    {\n      case: 'should not change correct link references',\n      input: `\n# Third Document\nAll the link references are correct in this file.\n\n[[doc1]]\n[[doc2]]\n\n\n[doc1]: doc1 \"First\"\n[doc2]: doc2 \"Second\"\n`,\n      expected: `\n# Third Document\nAll the link references are correct in this file.\n\n[[doc1]]\n[[doc2]]\n\n\n[doc1]: doc1 \"First\"\n[doc2]: doc2 \"Second\"\n`,\n    },\n    {\n      case: 'should put links with spaces in angel brackets',\n      input: `\n# Angel reference\n\n[[Angel note]]\n`,\n      expected: `\n# Angel reference\n\n[[Angel note]]\n\n[Angel note]: <Angel note> \"Angel note\"\n`,\n    },\n    {\n      case: 'should not remove explicitly entered link references',\n      input: `\n# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference]\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkreference]: https://foambubble.github.io/\n`,\n      expected: `\n# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference]\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkreference]: https://foambubble.github.io/\n`,\n    },\n    {\n      case: 'should not change explicitly entered link references',\n      input: `\n# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference].\nI also want a [[doc1]].\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkreference]: https://foambubble.github.io/\n`,\n      expected: `\n# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference].\nI also want a [[doc1]].\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkreference]: https://foambubble.github.io/\n\n[doc1]: doc1 \"First\"\n`,\n    },\n    {\n      case: 'should handle empty file with no wikilinks and no definitions',\n      input: `\n# Empty Document\n\nJust some text without any links.\n`,\n      expected: `\n# Empty Document\n\nJust some text without any links.\n`,\n    },\n    {\n      case: 'should handle wikilinks with aliases',\n      input: `\n# Document with aliases\n\n[[doc1|Custom Alias]] and [[doc2|Another Alias]]\n`,\n      expected: `\n# Document with aliases\n\n[[doc1|Custom Alias]] and [[doc2|Another Alias]]\n\n[doc1|Custom Alias]: doc1 \"First\"\n[doc2|Another Alias]: doc2 \"Second\"\n`,\n    },\n    {\n      case: 'should generate only one definition for multiple references to the same link',\n      input: `\n# Multiple references\n\nFirst mention: [[doc1]]\nSecond mention: [[doc1]]\nThird mention: [[doc1]]\n`,\n      expected: `\n# Multiple references\n\nFirst mention: [[doc1]]\nSecond mention: [[doc1]]\nThird mention: [[doc1]]\n\n[doc1]: doc1 \"First\"\n`,\n    },\n    {\n      case: 'should handle link definitions in the middle of content',\n      input: `\n# Document\n\n[[doc1]]\n\n[doc1]: doc1 \"First\"\n\nSome more content here.\n\n[[doc2]]\n`,\n      expected: `\n# Document\n\n[[doc1]]\n\n[doc1]: doc1 \"First\"\n\nSome more content here.\n\n[[doc2]]\n\n[doc2]: doc2 \"Second\"\n`,\n    },\n    {\n      case: 'should handle orphaned wikilinks without corresponding notes',\n      input: `\n# Document with broken links\n\n[[doc1]] [[nonexistent]] [[another-missing]]\n`,\n      expected: `\n# Document with broken links\n\n[[doc1]] [[nonexistent]] [[another-missing]]\n\n[doc1]: doc1 \"First\"\n`,\n    },\n    {\n      case: 'should handle file with only blank lines at end',\n      input: `\n\n`,\n      expected: `\n\n`,\n    },\n    {\n      case: 'should handle empty files',\n      input: '',\n      expected: '',\n    },\n    {\n      case: 'should handle link definitions with different quote styles',\n      input: `\n# Mixed quotes\n\n[[doc1]] [[doc2]]\n\n[doc1]: doc1 'First'\n[doc2]: doc2 \"Second\"\n`,\n      expected: `\n# Mixed quotes\n\n[[doc1]] [[doc2]]\n\n[doc1]: doc1 'First'\n[doc2]: doc2 \"Second\"\n`,\n    },\n    // TODO\n    //     {\n    //       case: 'should append new link references to existing ones without blank lines',\n    //       input: `\n    // [[doc1]] [[doc2]]\n\n    // [doc1]: doc1 \"First\"\n    // `,\n    //       expected: `\n    // [[doc1]] [[doc2]]\n\n    // [doc1]: doc1 \"First\"\n    // [doc2]: doc2 \"Second\"\n    // `,\n    //     },\n  ];\n\n  testCases.forEach(testCase => {\n    // eslint-disable-next-line jest/valid-title\n    it(testCase.case, async () => {\n      const workspace = createTestWorkspace([URI.file('/')]);\n      const workspaceNotes = [\n        { uri: '/doc1.md', title: 'First' },\n        { uri: '/doc2.md', title: 'Second' },\n        { uri: '/file-without-title.md', title: 'file-without-title' },\n        { uri: '/Angel note.md', title: 'Angel note' },\n      ];\n      workspaceNotes.forEach(note => {\n        workspace.set(createTestNote({ uri: note.uri, title: note.title }));\n      });\n\n      const noteText = testCase.input;\n      const note = parser.parse(URI.file('/note.md'), textForNote(noteText));\n      const actual = await generateLinkReferences(\n        note,\n        noteText,\n        EOL,\n        workspace,\n        false\n      );\n      const updated = TextEdit.apply(noteText, actual);\n\n      expect(updated).toBe(textForNote(testCase.expected));\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/generate-link-references.ts",
    "content": "import { NoteLinkDefinition, Resource } from '../model/note';\nimport { Range } from '../model/range';\nimport { createMarkdownReferences } from '../services/markdown-provider';\nimport { FoamWorkspace } from '../model/workspace';\nimport { TextEdit } from '../services/text-edit';\nimport { getLinkDefinitions } from '../services/markdown-parser';\n\nexport const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # \"Autogenerated link references for markdown compatibility\"`;\nexport const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # \"Autogenerated link references\"`;\n\nexport const generateLinkReferences = async (\n  note: Resource,\n  currentNoteText: string,\n  eol: string,\n  workspace: FoamWorkspace,\n  includeExtensions: boolean\n): Promise<TextEdit[]> => {\n  if (!note) {\n    return [];\n  }\n\n  const lines = currentNoteText.split(eol);\n  const nLines = lines.length;\n\n  const updatedWikilinkDefinitions = createMarkdownReferences(\n    workspace,\n    note,\n    includeExtensions\n  );\n\n  const existingWikilinkDefinitions = getLinkDefinitions(currentNoteText);\n\n  const toAddWikilinkDefinitions = updatedWikilinkDefinitions.filter(\n    newDef =>\n      !existingWikilinkDefinitions.some(\n        existingDef => existingDef.label === newDef.label\n      )\n  );\n  const toRemovedWikilinkDefinitions = existingWikilinkDefinitions.filter(\n    existingDef =>\n      !updatedWikilinkDefinitions.some(\n        newDef => newDef.label === existingDef.label\n      )\n  );\n\n  const edits: TextEdit[] = [];\n\n  // Remove old definitions\n  for (const def of toRemovedWikilinkDefinitions) {\n    edits.push({ range: def.range, newText: '' });\n  }\n\n  // Add new definitions\n  if (toAddWikilinkDefinitions.length > 0) {\n    // find the last non-empty line to append the definitions after it\n    const lastLineIndex = nLines - 1;\n    let insertLineIndex = lastLineIndex;\n    while (insertLineIndex > 0 && lines[insertLineIndex].trim() === '') {\n      insertLineIndex--;\n    }\n\n    const definitions = toAddWikilinkDefinitions.map(def =>\n      NoteLinkDefinition.format(def)\n    );\n    const text = eol + eol + definitions.join(eol) + eol;\n\n    edits.push({\n      range: Range.create(\n        insertLineIndex,\n        lines[insertLineIndex].length,\n        lastLineIndex,\n        lines[lastLineIndex].length\n      ),\n      newText: text,\n    });\n  }\n\n  return edits;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/janitor/index.ts",
    "content": "export { generateLinkReferences } from './generate-link-references';\nexport { generateHeading } from './generate-headings';\nexport { convertLinkFormat } from './convert-links-format';\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/foam.ts",
    "content": "import { IDisposable } from '../common/lifecycle';\nimport { IDataStore, IMatcher, IWatcher } from '../services/datastore';\nimport { URI } from './uri';\nimport { FoamWorkspace } from './workspace';\nimport { FoamGraph } from './graph';\nimport { ResourceParser } from './note';\nimport { ResourceProvider } from './provider';\nimport { FoamTags } from './tags';\nimport { FoamEmbeddings } from '../../ai/model/embeddings';\nimport { InMemoryEmbeddingCache } from '../../ai/model/in-memory-embedding-cache';\nimport { EmbeddingProvider } from '../../ai/services/embedding-provider';\nimport { NoOpEmbeddingProvider } from '../../ai/services/noop-embedding-provider';\nimport { Logger, withTiming, withTimingAsync } from '../utils/log';\n\nexport interface Services {\n  dataStore: IDataStore;\n  parser: ResourceParser;\n  matcher: IMatcher;\n}\n\nexport interface Foam extends IDisposable {\n  services: Services;\n  workspace: FoamWorkspace;\n  graph: FoamGraph;\n  tags: FoamTags;\n  embeddings: FoamEmbeddings;\n}\n\nexport const bootstrap = async (\n  roots: URI[],\n  matcher: IMatcher,\n  watcher: IWatcher | undefined,\n  dataStore: IDataStore,\n  parser: ResourceParser,\n  initialProviders: ResourceProvider[],\n  defaultExtension: string = '.md',\n  embeddingProvider?: EmbeddingProvider\n) => {\n  const workspace = await withTimingAsync(\n    () =>\n      FoamWorkspace.fromProviders(\n        roots,\n        initialProviders,\n        dataStore,\n        defaultExtension\n      ),\n    ms => Logger.info(`Workspace loaded in ${ms}ms`)\n  );\n\n  const graph = withTiming(\n    () => FoamGraph.fromWorkspace(workspace, true),\n    ms => Logger.info(`Graph loaded in ${ms}ms`)\n  );\n\n  const tags = withTiming(\n    () => FoamTags.fromWorkspace(workspace, true),\n    ms => Logger.info(`Tags loaded in ${ms}ms`)\n  );\n\n  embeddingProvider = embeddingProvider ?? new NoOpEmbeddingProvider();\n  const embeddings = FoamEmbeddings.fromWorkspace(\n    workspace,\n    embeddingProvider,\n    true,\n    new InMemoryEmbeddingCache()\n  );\n\n  if (await embeddingProvider.isAvailable()) {\n    Logger.info('Embeddings service initialized');\n  } else {\n    Logger.warn(\n      'Embedding provider not available. Semantic features will be disabled.'\n    );\n  }\n\n  watcher?.onDidChange(async uri => {\n    if (matcher.isMatch(uri)) {\n      await workspace.fetchAndSet(uri);\n    }\n  });\n  watcher?.onDidCreate(async uri => {\n    await matcher.refresh();\n    if (matcher.isMatch(uri)) {\n      await workspace.fetchAndSet(uri);\n    }\n  });\n  watcher?.onDidDelete(uri => {\n    workspace.delete(uri);\n  });\n\n  const foam: Foam = {\n    workspace,\n    graph,\n    tags,\n    embeddings,\n    services: {\n      parser,\n      dataStore,\n      matcher,\n    },\n    dispose: () => {\n      workspace.dispose();\n      graph.dispose();\n      embeddings.dispose();\n    },\n  };\n\n  return foam;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/graph.test.ts",
    "content": "import { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { FoamGraph } from './graph';\nimport { URI } from './uri';\n\ndescribe('Graph', () => {\n  it('should use wikilink slugs to connect nodes', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/page-a.md',\n      links: [\n        { slug: 'page-b' },\n        { slug: 'page-c' },\n        { slug: 'Page D' },\n        { slug: 'page e' },\n      ],\n    });\n    const noteB = createTestNote({\n      uri: '/page-b.md',\n      links: [{ slug: 'page-a' }],\n    });\n    const noteC = createTestNote({ uri: '/page-c.md' });\n    const noteD = createTestNote({ uri: '/Page D.md' });\n    const noteE = createTestNote({ uri: '/page e.md' });\n\n    workspace.set(noteA).set(noteB).set(noteC).set(noteD).set(noteE);\n    const graph = FoamGraph.fromWorkspace(workspace);\n\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      noteB.uri,\n      noteC.uri,\n      noteD.uri,\n      noteE.uri,\n    ]);\n  });\n\n  it('should include resources and placeholders', () => {\n    const ws = createTestWorkspace();\n    ws.set(\n      createTestNote({\n        uri: '/page-a.md',\n        links: [{ slug: 'placeholder-link' }],\n      })\n    );\n    ws.set(createTestNote({ uri: '/file.pdf' }));\n\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(\n      graph\n        .getAllNodes()\n        .map(uri => uri.path)\n        .sort()\n    ).toEqual(['/file.pdf', '/page-a.md', 'placeholder-link']);\n  });\n\n  it('should support multiple connections between the same resources', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/note-a.md',\n    });\n    const noteB = createTestNote({\n      uri: '/note-b.md',\n      links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],\n    });\n    const ws = createTestWorkspace().set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws);\n    expect(graph.getBacklinks(noteA.uri)).toEqual([\n      {\n        source: noteB.uri,\n        target: noteA.uri,\n        link: expect.objectContaining({ type: 'link' }),\n      },\n      {\n        source: noteB.uri,\n        target: noteA.uri,\n        link: expect.objectContaining({ type: 'link' }),\n      },\n    ]);\n  });\n\n  it('should keep the connection when removing a single link amongst several between two resources', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/note-a.md',\n    });\n    const noteB = createTestNote({\n      uri: '/note-b.md',\n      links: [{ to: noteA.uri.path }, { to: noteA.uri.path }],\n    });\n    const ws = createTestWorkspace().set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getBacklinks(noteA.uri).length).toEqual(2);\n\n    const noteBBis = createTestNote({\n      uri: '/note-b.md',\n      links: [{ to: noteA.uri.path }],\n    });\n    ws.set(noteBBis);\n    expect(graph.getBacklinks(noteA.uri).length).toEqual(1);\n\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should create inbound connections for target note', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const ws = createTestWorkspace()\n      .set(noteA)\n      .set(\n        createTestNote({\n          uri: '/somewhere/page-b.md',\n          links: [{ slug: 'page-a' }],\n        })\n      )\n      .set(\n        createTestNote({\n          uri: '/path/another/page-c.md',\n          links: [{ slug: '/path/to/page-a' }],\n        })\n      )\n      .set(\n        createTestNote({\n          uri: '/absolute/path/page-d.md',\n          links: [{ slug: '../to/page-a.md' }],\n        })\n      );\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(\n      graph\n        .getBacklinks(noteA.uri)\n        .map(link => link.source.path)\n        .sort()\n    ).toEqual(['/path/another/page-c.md', '/somewhere/page-b.md']);\n  });\n\n  it('should create inbound connections when targeting a section', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b#section 2' }],\n    });\n    const noteB = createTestNote({\n      uri: '/somewhere/page-b.md',\n    });\n    const ws = createTestWorkspace().set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getBacklinks(noteB.uri).length).toEqual(1);\n  });\n\n  it('should support attachments', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [\n        // wikilink with extension\n        { slug: 'attachment-a.pdf' },\n        // wikilink without extension\n        { slug: 'attachment-b' },\n      ],\n    });\n    const attachmentA = createTestNote({\n      uri: '/path/to/more/attachment-a.pdf',\n    });\n    const attachmentB = createTestNote({\n      uri: '/path/to/more/attachment-b.pdf',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(attachmentA).set(attachmentB);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getBacklinks(attachmentA.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    // Attachments require extension\n    expect(graph.getBacklinks(attachmentB.uri).map(l => l.source)).toEqual([]);\n  });\n\n  it('should resolve conflicts alphabetically - part 1', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'attachment-a.pdf' }],\n    });\n    const attachmentA = createTestNote({\n      uri: '/path/to/more/attachment-a.pdf',\n    });\n    const attachmentABis = createTestNote({\n      uri: '/path/to/attachment-a.pdf',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(attachmentA).set(attachmentABis);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      attachmentABis.uri,\n    ]);\n  });\n\n  it('should resolve conflicts alphabetically - part 2', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'attachment-a.pdf' }],\n    });\n    const attachmentA = createTestNote({\n      uri: '/path/to/more/attachment-a.pdf',\n    });\n    const attachmentABis = createTestNote({\n      uri: '/path/to/attachment-a.pdf',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(attachmentABis).set(attachmentA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      attachmentABis.uri,\n    ]);\n  });\n});\n\ndescribe('Placeholders', () => {\n  it('should treat direct links to non-existing files as placeholders', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/somewhere/from/page-a.md',\n      links: [{ to: '../page-b.md' }, { to: '/path/to/page-c.md' }],\n    });\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getAllConnections()[0]).toEqual({\n      source: noteA.uri,\n      target: URI.placeholder('/somewhere/page-b.md'),\n      link: expect.objectContaining({ type: 'link' }),\n    });\n    expect(graph.getAllConnections()[1]).toEqual({\n      source: noteA.uri,\n      target: URI.placeholder('/path/to/page-c.md'),\n      link: expect.objectContaining({ type: 'link' }),\n    });\n  });\n\n  it('should treat wikilinks without matching file as placeholders', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/somewhere/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getAllConnections()[0]).toEqual({\n      source: noteA.uri,\n      target: URI.placeholder('page-b'),\n      link: expect.objectContaining({ type: 'wikilink' }),\n    });\n  });\n\n  it('should treat wikilink with definition to non-existing file as placeholders', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/somewhere/page-a.md',\n      links: [\n        { slug: 'page-b', definitionUrl: './page-b.md' },\n        { slug: 'page-c', definitionUrl: '/path/to/page-c.md' },\n      ],\n    });\n    ws.set(noteA).set(\n      createTestNote({ uri: '/different/location/for/note-b.md' })\n    );\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getAllConnections()[0]).toEqual({\n      source: noteA.uri,\n      target: URI.placeholder('/somewhere/page-b.md'),\n      link: expect.objectContaining({ type: 'wikilink' }),\n    });\n    expect(graph.getAllConnections()[1]).toEqual({\n      source: noteA.uri,\n      target: URI.placeholder('/path/to/page-c.md'),\n      link: expect.objectContaining({ type: 'wikilink' }),\n    });\n  });\n\n  it('should work with a placeholder named like a JS prototype property', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/page-a.md',\n      links: [{ slug: 'constructor' }],\n    });\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(\n      graph\n        .getAllNodes()\n        .map(uri => uri.path)\n        .sort()\n    ).toEqual(['/page-a.md', 'constructor']);\n  });\n});\n\ndescribe('Regenerating graph after workspace changes', () => {\n  it('should update links when modifying a resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n      links: [{ slug: 'page-c' }],\n    });\n    const noteC = createTestNote({\n      uri: '/path/to/more/page-c.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB).set(noteC);\n    let graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([\n      noteB.uri,\n    ]);\n\n    // update the note\n    const noteABis = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-c' }],\n    });\n    ws.set(noteABis);\n    // change is not propagated immediately\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([\n      noteB.uri,\n    ]);\n\n    // recompute the links\n    graph = FoamGraph.fromWorkspace(ws);\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);\n    expect(\n      graph\n        .getBacklinks(noteC.uri)\n        .map(link => link.source.path)\n        .sort()\n    ).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);\n    graph.dispose();\n    ws.dispose();\n  });\n\n  it('should produce a placeholder for wikilinks pointing to a removed resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(ws.get(noteB.uri).type).toEqual('note');\n\n    // remove note-b\n    ws.delete(noteB.uri);\n    const graph2 = FoamGraph.fromWorkspace(ws);\n\n    expect(() => ws.get(noteB.uri)).toThrow();\n    expect(graph2.contains(URI.placeholder('page-b'))).toBeTruthy();\n  });\n\n  it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      URI.placeholder('page-b'),\n    ]);\n    expect(graph.contains(URI.placeholder('page-b'))).toBeTruthy();\n\n    // add note-b\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n\n    ws.set(noteB);\n    FoamGraph.fromWorkspace(ws);\n\n    expect(() => ws.get(URI.placeholder('page-b'))).toThrow();\n    expect(ws.get(noteB.uri).type).toEqual('note');\n  });\n\n  it('should produce a placeholder for direct links pointing to a removed resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(ws.get(noteB.uri).type).toEqual('note');\n\n    // remove note-b\n    ws.delete(noteB.uri);\n    const graph2 = FoamGraph.fromWorkspace(ws);\n\n    expect(() => ws.get(noteB.uri)).toThrow();\n    expect(\n      graph2.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeTruthy();\n  });\n\n  it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      URI.placeholder('/path/to/another/page-b.md'),\n    ]);\n    expect(() =>\n      ws.get(URI.placeholder('/path/to/another/page-b.md'))\n    ).toThrow();\n\n    // add note-b\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n\n    ws.set(noteB);\n    FoamGraph.fromWorkspace(ws);\n\n    expect(() => ws.get(URI.placeholder('page-b'))).toThrow();\n    expect(ws.get(noteB.uri).type).toEqual('note');\n  });\n\n  it('should remove the placeholder from graph when removing all links to it', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const ws = createTestWorkspace().set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws);\n    expect(\n      graph.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeTruthy();\n\n    // update the note\n    const noteABis = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [],\n    });\n    ws.set(noteABis);\n    const graph2 = FoamGraph.fromWorkspace(ws);\n\n    expect(\n      graph2.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeFalsy();\n  });\n});\n\ndescribe('Updating graph on workspace state', () => {\n  it('should automatically update the links when modifying a resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n      links: [{ slug: 'page-c' }],\n    });\n    const noteC = createTestNote({\n      uri: '/path/to/more/page-c.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB).set(noteC);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(graph.getBacklinks(noteC.uri).map(l => l.source)).toEqual([\n      noteB.uri,\n    ]);\n\n    // update the note\n    const noteABis = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-c' }],\n    });\n    ws.set(noteABis);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteC.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([]);\n    expect(\n      graph\n        .getBacklinks(noteC.uri)\n        .map(link => link.source.path)\n        .sort()\n    ).toEqual(['/path/to/another/page-b.md', '/path/to/page-a.md']);\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should produce a placeholder for wikilinks pointing to a removed resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(ws.get(noteB.uri).type).toEqual('note');\n\n    // remove note-b\n    ws.delete(noteB.uri);\n\n    expect(() => ws.get(noteB.uri)).toThrow();\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should turn a placeholder into a connection when adding a resource matching a wikilink', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'page-b' }],\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      URI.placeholder('page-b'),\n    ]);\n\n    // add note-b\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n\n    ws.set(noteB);\n\n    expect(() => ws.get(URI.placeholder('page-b'))).toThrow();\n    expect(ws.get(noteB.uri).type).toEqual('note');\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should produce a placeholder for direct links pointing to a removed resource', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA).set(noteB);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([noteB.uri]);\n    expect(graph.getBacklinks(noteB.uri).map(l => l.source)).toEqual([\n      noteA.uri,\n    ]);\n    expect(ws.get(noteB.uri).type).toEqual('note');\n\n    // remove note-b\n    ws.delete(noteB.uri);\n\n    expect(() => ws.get(noteB.uri)).toThrow();\n    expect(\n      graph.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeTruthy();\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should turn a placeholder into a connection when adding a resource matching a direct link', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n\n    expect(graph.getLinks(noteA.uri).map(l => l.target)).toEqual([\n      URI.placeholder('/path/to/another/page-b.md'),\n    ]);\n    expect(\n      graph.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeTruthy();\n\n    // add note-b\n    const noteB = createTestNote({\n      uri: '/path/to/another/page-b.md',\n    });\n\n    ws.set(noteB);\n\n    expect(() => ws.get(URI.placeholder('page-b'))).toThrow();\n    expect(ws.get(noteB.uri).type).toEqual('note');\n    ws.dispose();\n    graph.dispose();\n  });\n\n  it('should remove the placeholder from graph when removing all links to it', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: '/path/to/another/page-b.md' }],\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n    const graph = FoamGraph.fromWorkspace(ws, true);\n    expect(\n      graph.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeTruthy();\n\n    // update the note\n    const noteABis = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [],\n    });\n    ws.set(noteABis);\n    expect(\n      graph.contains(URI.placeholder('/path/to/another/page-b.md'))\n    ).toBeFalsy();\n    ws.dispose();\n    graph.dispose();\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/graph.ts",
    "content": "import { debounce } from 'lodash';\nimport { ResourceLink } from './note';\nimport { URI } from './uri';\nimport { FoamWorkspace } from './workspace';\nimport { IDisposable } from '../common/lifecycle';\nimport { Logger } from '../utils/log';\nimport { Emitter } from '../common/event';\n\nexport type Connection = {\n  source: URI;\n  target: URI;\n  link: ResourceLink;\n};\n\nconst pathToPlaceholderId = (value: string) => value;\nconst uriToPlaceholderId = (uri: URI) => pathToPlaceholderId(uri.path);\n\nexport class FoamGraph implements IDisposable {\n  /**\n   * Placehoders by key / slug / value\n   */\n  public readonly placeholders: Map<string, URI> = new Map();\n  /**\n   * Maps the connections starting from a URI\n   */\n  public readonly links: Map<string, Connection[]> = new Map();\n  /**\n   * Maps the connections arriving to a URI\n   */\n  public readonly backlinks: Map<string, Connection[]> = new Map();\n\n  private onDidUpdateEmitter = new Emitter<void>();\n  onDidUpdate = this.onDidUpdateEmitter.event;\n\n  /**\n   * List of disposables to destroy with the workspace\n   */\n  private disposables: IDisposable[] = [];\n\n  constructor(private readonly workspace: FoamWorkspace) {}\n\n  public contains(uri: URI): boolean {\n    return this.getConnections(uri).length > 0;\n  }\n\n  public getAllNodes(): URI[] {\n    return [\n      ...Array.from(this.placeholders.values()),\n      ...this.workspace.list().map(r => r.uri),\n    ];\n  }\n\n  public getAllConnections(): Connection[] {\n    return Array.from(this.links.values()).flat();\n  }\n\n  public getConnections(uri: URI): Connection[] {\n    return [\n      ...(this.links.get(uri.path) || []),\n      ...(this.backlinks.get(uri.path) || []),\n    ];\n  }\n\n  public getLinks(uri: URI): Connection[] {\n    return this.links.get(uri.path) ?? [];\n  }\n\n  public getBacklinks(uri: URI): Connection[] {\n    return this.backlinks.get(uri.path) ?? [];\n  }\n\n  /**\n   * Computes all the links in the workspace, connecting notes and\n   * creating placeholders.\n   *\n   * @param workspace the target workspace\n   * @param keepMonitoring whether to recompute the links when the workspace changes\n   * @param debounceFor how long to wait between change detection and graph update\n   * @returns the FoamGraph\n   */\n  public static fromWorkspace(\n    workspace: FoamWorkspace,\n    keepMonitoring = false,\n    debounceFor = 0\n  ): FoamGraph {\n    const graph = new FoamGraph(workspace);\n    graph.update();\n    if (keepMonitoring) {\n      const updateGraph =\n        debounceFor > 0\n          ? debounce(graph.update.bind(graph), 500)\n          : graph.update.bind(graph);\n      graph.disposables.push(\n        workspace.onDidAdd(updateGraph),\n        workspace.onDidUpdate(updateGraph),\n        workspace.onDidDelete(updateGraph)\n      );\n    }\n    return graph;\n  }\n\n  public update() {\n    const start = Date.now();\n    this.backlinks.clear();\n    this.links.clear();\n    this.placeholders.clear();\n\n    for (const resource of this.workspace.resources()) {\n      for (const link of resource.links) {\n        try {\n          const targetUri = this.workspace.resolveLink(resource, link);\n          this.connect(resource.uri, targetUri, link);\n        } catch (e) {\n          Logger.error(\n            `Error while resolving link ${\n              link.rawText\n            } in ${resource.uri.toFsPath()}, skipping.`,\n            link,\n            e\n          );\n        }\n      }\n    }\n\n    const end = Date.now();\n    Logger.debug(`Graph updated in ${end - start}ms`);\n    this.onDidUpdateEmitter.fire();\n  }\n\n  private connect(source: URI, target: URI, link: ResourceLink) {\n    const connection = { source, target, link };\n\n    if (!this.links.has(source.path)) {\n      this.links.set(source.path, []);\n    }\n    this.links.get(source.path)?.push(connection);\n\n    if (!this.backlinks.has(target.path)) {\n      this.backlinks.set(target.path, []);\n    }\n    this.backlinks.get(target.path)?.push(connection);\n\n    if (target.isPlaceholder()) {\n      this.placeholders.set(uriToPlaceholderId(target), target);\n    }\n    return this;\n  }\n\n  public dispose(): void {\n    this.onDidUpdateEmitter.dispose();\n    this.disposables.forEach(d => d.dispose());\n    this.disposables = [];\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/location.ts",
    "content": "import { Range } from './range';\nimport { URI } from './uri';\n\n/**\n * Represents a location inside a resource, such as a line\n * inside a text file.\n */\nexport interface Location<T> {\n  /**\n   * The resource identifier of this location.\n   */\n  uri: URI;\n  /**\n   * The document range of this locations.\n   */\n  range: Range;\n  /**\n   * The data associated to this location.\n   */\n  data: T;\n}\n\nexport abstract class Location<T> {\n  static create<T>(uri: URI, range: Range, data: T): Location<T> {\n    return { uri, range, data };\n  }\n\n  static forObjectWithRange<T extends { range: Range }>(\n    uri: URI,\n    obj: T\n  ): Location<T> {\n    return Location.create(uri, obj.range, obj);\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/note.test.ts",
    "content": "import { createNoteFromMarkdown } from '../../test/test-utils';\nimport { Position } from './position';\nimport { URI } from './uri';\nimport { Resource, Block } from './note';\n\ndescribe('Resource', () => {\n  describe('getSectionAtPosition', () => {\n    it('should return the section when position is on the heading line', () => {\n      const note = createNoteFromMarkdown(\n        '/page.md',\n        `# Introduction\n\n## Methods\n`\n      );\n\n      const section = Resource.getSectionAtPosition(\n        note,\n        Position.create(0, 3)\n      );\n      expect(section?.label).toBe('Introduction');\n    });\n\n    it('should return the section when position is in the section body', () => {\n      const note = createNoteFromMarkdown(\n        '/page.md',\n        `# Introduction\n\nBody text here.\n`\n      );\n\n      const section = Resource.getSectionAtPosition(\n        note,\n        Position.create(2, 0)\n      );\n      expect(section?.label).toBe('Introduction');\n    });\n\n    it('should return the correct section when cursor is mid-line on the heading', () => {\n      const note = createNoteFromMarkdown(\n        '/page.md',\n        `Some content\n\nAnother line\n\n## My Long Heading\n`\n      );\n\n      // \"## My Long Heading\" is on line 4\n      const section = Resource.getSectionAtPosition(\n        note,\n        Position.create(4, 10)\n      );\n      expect(section?.label).toBe('My Long Heading');\n    });\n\n    it('should return the correct section among multiple sections', () => {\n      const note = createNoteFromMarkdown(\n        '/page.md',\n        `# Section One\n\nContent one.\n\n## Section Two\n\nContent two.\n`\n      );\n\n      expect(\n        Resource.getSectionAtPosition(note, Position.create(0, 0))?.label\n      ).toBe('Section One');\n      expect(\n        Resource.getSectionAtPosition(note, Position.create(2, 0))?.label\n      ).toBe('Section One');\n      expect(\n        Resource.getSectionAtPosition(note, Position.create(5, 0))?.label\n      ).toBe('Section Two');\n      expect(\n        Resource.getSectionAtPosition(note, Position.create(6, 0))?.label\n      ).toBe('Section Two');\n    });\n\n    it('should return undefined when there are no sections', () => {\n      const note = createNoteFromMarkdown(\n        '/page.md',\n        `Just some plain content.`\n      );\n\n      const section = Resource.getSectionAtPosition(\n        note,\n        Position.create(0, 0)\n      );\n      expect(section).toBeUndefined();\n    });\n\n    it('should return undefined for a URI that does not exist', () => {\n      const note = createNoteFromMarkdown('/page.md', `# Heading\\n`);\n      const ghost = { ...note, uri: URI.file('/ghost.md') };\n\n      // Position before any section content\n      const section = Resource.getSectionAtPosition(\n        ghost,\n        Position.create(99, 0)\n      );\n      expect(section).toBeUndefined();\n    });\n  });\n});\n\ndescribe('Block', () => {\n  describe('generateId', () => {\n    it('should return a non-empty string', () => {\n      expect(Block.generateId().length).toBeGreaterThan(0);\n    });\n\n    it('should only contain characters valid in a block anchor ([a-z0-9])', () => {\n      for (let i = 0; i < 50; i++) {\n        expect(Block.generateId()).toMatch(/^[a-z0-9]+$/);\n      }\n    });\n\n    it('should return different values on successive calls', () => {\n      const ids = new Set(Array.from({ length: 20 }, () => Block.generateId()));\n      // Extremely unlikely to collide 20 times in a row\n      expect(ids.size).toBeGreaterThan(1);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/note.ts",
    "content": "import { URI } from './uri';\nimport { Range } from './range';\nimport { Position } from './position';\n\nexport interface ResourceLink {\n  type: 'wikilink' | 'link';\n  rawText: string;\n  range: Range;\n  isEmbed: boolean;\n  definition?: string | NoteLinkDefinition;\n}\n\nexport abstract class ResourceLink {\n  /**\n   * Check if this is any kind of reference-style link (resolved or unresolved)\n   */\n  static isReferenceStyleLink(link: ResourceLink): boolean {\n    return link.definition !== undefined;\n  }\n\n  /**\n   * Check if this is a reference-style link with unresolved definition\n   */\n  static isUnresolvedReference(\n    link: ResourceLink\n  ): link is ResourceLink & { definition: string } {\n    return typeof link.definition === 'string';\n  }\n\n  /**\n   * Check if this is a reference-style link with resolved definition\n   */\n  static isResolvedReference(\n    link: ResourceLink\n  ): link is ResourceLink & { definition: NoteLinkDefinition } {\n    return typeof link.definition === 'object' && link.definition !== null;\n  }\n\n  /**\n   * Check if this is a regular inline link (not reference-style)\n   */\n  static isRegularLink(link: ResourceLink): boolean {\n    return link.definition === undefined;\n  }\n}\n\nexport interface NoteLinkDefinition {\n  label: string;\n  url: string;\n  title?: string;\n  range?: Range;\n}\n\nexport abstract class NoteLinkDefinition {\n  static format(definition: NoteLinkDefinition) {\n    const url =\n      definition.url.indexOf(' ') > 0 ? `<${definition.url}>` : definition.url;\n    let text = `[${definition.label}]: ${url}`;\n    if (definition.title) {\n      text = `${text} \"${definition.title}\"`;\n    }\n\n    return text;\n  }\n\n  static isEqual(def1: NoteLinkDefinition, def2: NoteLinkDefinition): boolean {\n    return (\n      def1.label === def2.label &&\n      def1.url === def2.url &&\n      def1.title === def2.title\n    );\n  }\n}\n\nexport interface Tag {\n  label: string;\n  range: Range;\n}\n\nexport interface Alias {\n  title: string;\n  range: Range;\n}\n\nexport interface Section {\n  label: string;\n  range: Range;\n}\n\nexport type BlockType =\n  | 'paragraph'\n  | 'list-item'\n  | 'list'\n  | 'blockquote'\n  | 'code'\n  | 'table'\n  | 'heading';\n\nexport interface Block {\n  id: string;\n  /** The range of the block's content (excludes the `^id` marker). */\n  range: Range;\n  /** The range of the `^id` marker itself */\n  markerRange: Range;\n  type: BlockType;\n}\n\nexport abstract class Block {\n  /**\n   * Generates a random block ID suitable for use as a `^blockid` anchor.\n   * Produces 6 lowercase alphanumeric characters, e.g. `\"k4f2m1\"`.\n   */\n  static generateId(): string {\n    return Math.random().toString(36).slice(2, 8);\n  }\n}\n\nexport interface Resource {\n  uri: URI;\n  type: string;\n  title: string;\n  properties: any;\n  sections: Section[];\n  blocks: Block[];\n  tags: Tag[];\n  aliases: Alias[];\n  links: ResourceLink[];\n}\n\nexport interface ResourceParser {\n  parse: (uri: URI, text: string) => Resource;\n}\n\nexport abstract class Resource {\n  public static sortByTitle(a: Resource, b: Resource) {\n    return a.title.localeCompare(b.title);\n  }\n\n  public static sortByPath(a: Resource, b: Resource) {\n    return a.uri.path.localeCompare(b.uri.path);\n  }\n\n  public static isResource(thing: any): thing is Resource {\n    if (!thing) {\n      return false;\n    }\n    return (\n      (thing as Resource).uri instanceof URI &&\n      typeof (thing as Resource).title === 'string' &&\n      typeof (thing as Resource).type === 'string' &&\n      typeof (thing as Resource).properties === 'object' &&\n      typeof (thing as Resource).tags === 'object' &&\n      typeof (thing as Resource).aliases === 'object' &&\n      typeof (thing as Resource).links === 'object'\n    );\n  }\n\n  public static findSection(resource: Resource, label: string): Section | null {\n    if (label) {\n      return resource.sections.find(s => s.label === label) ?? null;\n    }\n    return null;\n  }\n\n  public static findBlock(resource: Resource, id: string): Block | null {\n    if (id) {\n      return resource.blocks.find(b => b.id === id) ?? null;\n    }\n    return null;\n  }\n\n  /**\n   * Returns the deepest section whose range contains the given position, or\n   * undefined if the position does not fall within any section.\n   *\n   * Note: parent sections (e.g. h1) have ranges that extend to the end of the\n   * document and therefore overlap with their child sections (h2, h3, …).\n   * Iterating in reverse start-position order (sections are sorted by start)\n   * ensures the innermost/deepest section is returned.\n   */\n  public static getSectionAtPosition(\n    resource: Resource,\n    position: Position\n  ): Section | undefined {\n    if (!resource.sections) {\n      return undefined;\n    }\n    for (let i = resource.sections.length - 1; i >= 0; i--) {\n      if (Range.containsPosition(resource.sections[i].range, position)) {\n        return resource.sections[i];\n      }\n    }\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/position.ts",
    "content": "// Some code in this file coming from https://github.com/microsoft/vscode/\n// See LICENSE for details\n\nexport interface Position {\n  line: number;\n  character: number;\n}\n\nexport abstract class Position {\n  static create(line: number, character: number): Position {\n    return { line, character };\n  }\n\n  static Min(...positions: Position[]): Position {\n    if (positions.length === 0) {\n      throw new TypeError();\n    }\n    let result = positions[0];\n    for (let i = 1; i < positions.length; i++) {\n      const p = positions[i];\n      if (Position.isBefore(p, result!)) {\n        result = p;\n      }\n    }\n    return result;\n  }\n\n  static Max(...positions: Position[]): Position {\n    if (positions.length === 0) {\n      throw new TypeError();\n    }\n    let result = positions[0];\n    for (let i = 1; i < positions.length; i++) {\n      const p = positions[i];\n      if (Position.isAfter(p, result!)) {\n        result = p;\n      }\n    }\n    return result;\n  }\n\n  static isBefore(p1: Position, p2: Position): boolean {\n    if (p1.line < p2.line) {\n      return true;\n    }\n    if (p2.line < p1.line) {\n      return false;\n    }\n    return p1.character < p2.character;\n  }\n\n  static isBeforeOrEqual(p1: Position, p2: Position): boolean {\n    if (p1.line < p2.line) {\n      return true;\n    }\n    if (p2.line < p1.line) {\n      return false;\n    }\n    return p1.character <= p2.character;\n  }\n\n  static isAfter(p1: Position, p2: Position): boolean {\n    return !Position.isBeforeOrEqual(p1, p2);\n  }\n\n  static isAfterOrEqual(p1: Position, p2: Position): boolean {\n    return !Position.isBefore(p1, p2);\n  }\n\n  static isEqual(p1: Position, p2: Position): boolean {\n    return p1.line === p2.line && p1.character === p2.character;\n  }\n\n  static compareTo(p1: Position, p2: Position): number {\n    if (p1.line < p2.line) {\n      return -1;\n    } else if (p1.line > p2.line) {\n      return 1;\n    } else {\n      // equal line\n      if (p1.character < p2.character) {\n        return -1;\n      } else if (p1.character > p2.character) {\n        return 1;\n      } else {\n        // equal line and character\n        return 0;\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/provider.ts",
    "content": "import { IDisposable } from '../common/lifecycle';\nimport { Resource, ResourceLink } from './note';\nimport { URI } from './uri';\nimport { FoamWorkspace } from './workspace';\n\nexport interface ResourceProvider extends IDisposable {\n  supports: (uri: URI) => boolean;\n  readAsMarkdown: (uri: URI) => Promise<string | null>;\n  fetch: (uri: URI) => Promise<Resource | null>;\n  resolveLink: (\n    workspace: FoamWorkspace,\n    resource: Resource,\n    link: ResourceLink\n  ) => URI;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/range.ts",
    "content": "// Some code in this file coming from https://github.com/microsoft/vscode/\n// See LICENSE for details\n\nimport { Position } from './position';\n\nexport interface Range {\n  start: Position;\n  end: Position;\n}\n\nexport abstract class Range {\n  static create(\n    startLine: number,\n    startChar: number,\n    endLine?: number,\n    endChar?: number\n  ): Range {\n    const start: Position = {\n      line: startLine,\n      character: startChar,\n    };\n    const end: Position = {\n      line: endLine ?? startLine,\n      character: endChar ?? startChar,\n    };\n    return Range.createFromPosition(start, end);\n  }\n\n  static createFromPosition(start: Position, end?: Position) {\n    end = end ?? start;\n    let first = start;\n    let second = end;\n    if (Position.isAfter(start, end)) {\n      first = end;\n      second = start;\n    }\n    return {\n      start: {\n        line: first.line,\n        character: first.character,\n      },\n      end: {\n        line: second.line,\n        character: second.character,\n      },\n    };\n  }\n\n  static containsRange(range: Range, contained: Range): boolean {\n    return (\n      Range.containsPosition(range, contained.start) &&\n      Range.containsPosition(range, contained.end)\n    );\n  }\n\n  static containsPosition(range: Range, position: Position): boolean {\n    return (\n      Position.isAfterOrEqual(position, range.start) &&\n      Position.isBeforeOrEqual(position, range.end)\n    );\n  }\n\n  static isEqual(r1: Range, r2: Range): boolean {\n    return (\n      Position.isEqual(r1.start, r2.start) && Position.isEqual(r1.end, r2.end)\n    );\n  }\n\n  static isBefore(a: Range, b: Range): number {\n    return a.start.line - b.start.line || a.start.character - b.start.character;\n  }\n\n  static toString(range: Range): string {\n    return `${range.start.line}:${range.start.character} - ${range.end.line}:${range.end.character}`;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/tags.test.ts",
    "content": "import { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { FoamTags } from './tags';\nimport { Location } from './location';\n\ndescribe('FoamTags', () => {\n  it('Collects tags from a list of resources', () => {\n    const ws = createTestWorkspace();\n\n    const pageA = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary', 'secondary'],\n    });\n\n    const pageB = createTestNote({\n      uri: '/page-b.md',\n      title: 'Page B',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary', 'third'],\n    });\n\n    ws.set(pageA);\n    ws.set(pageB);\n\n    const tags = FoamTags.fromWorkspace(ws);\n    expect(tags.tags).toEqual(\n      new Map([\n        [\n          'primary',\n          [\n            Location.forObjectWithRange(pageA.uri, pageA.tags[0]),\n            Location.forObjectWithRange(pageB.uri, pageB.tags[0]),\n          ],\n        ],\n        ['secondary', [Location.forObjectWithRange(pageA.uri, pageA.tags[1])]],\n        ['third', [Location.forObjectWithRange(pageB.uri, pageB.tags[1])]],\n      ])\n    );\n  });\n\n  it('Updates an existing tag when a note is tagged with an existing tag', () => {\n    const ws = createTestWorkspace();\n\n    const page = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary'],\n    });\n    const taglessPage = createTestNote({\n      uri: '/page-b.md',\n      title: 'Page B',\n    });\n\n    ws.set(page);\n    ws.set(taglessPage);\n\n    const tags = FoamTags.fromWorkspace(ws);\n    expect(tags.tags).toEqual(\n      new Map([\n        ['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],\n      ])\n    );\n\n    const newPage = createTestNote({\n      uri: '/page-b.md',\n      title: 'Page B',\n      tags: ['primary'],\n    });\n\n    ws.set(newPage);\n    tags.update();\n\n    expect(tags.tags).toEqual(\n      new Map([\n        [\n          'primary',\n          [\n            Location.forObjectWithRange(page.uri, page.tags[0]),\n            Location.forObjectWithRange(newPage.uri, newPage.tags[0]),\n          ],\n        ],\n      ])\n    );\n  });\n\n  it('Replaces the tag when a note is updated with an altered tag', () => {\n    const ws = createTestWorkspace();\n\n    const page = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary'],\n    });\n\n    ws.set(page);\n\n    const tags = FoamTags.fromWorkspace(ws);\n    expect(tags.tags).toEqual(\n      new Map([\n        ['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],\n      ])\n    );\n\n    const pageEdited = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['new'],\n    });\n\n    ws.set(pageEdited);\n    tags.update();\n\n    expect(tags.tags).toEqual(\n      new Map([\n        [\n          'new',\n          [Location.forObjectWithRange(pageEdited.uri, pageEdited.tags[0])],\n        ],\n      ])\n    );\n  });\n\n  it('Updates the metadata of a tag when the note is moved', () => {\n    const ws = createTestWorkspace();\n\n    const page = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary'],\n    });\n    ws.set(page);\n\n    const tags = FoamTags.fromWorkspace(ws);\n    expect(tags.tags).toEqual(\n      new Map([\n        ['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],\n      ])\n    );\n\n    const pageEdited = createTestNote({\n      uri: '/new-place/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary'],\n    });\n\n    ws.delete(page.uri);\n    ws.set(pageEdited);\n    tags.update();\n\n    expect(tags.tags).toEqual(\n      new Map([\n        [\n          'primary',\n          [Location.forObjectWithRange(pageEdited.uri, pageEdited.tags[0])],\n        ],\n      ])\n    );\n  });\n\n  it('Updates the metadata of a tag when a note is deleted', () => {\n    const ws = createTestWorkspace();\n\n    const page = createTestNote({\n      uri: '/page-a.md',\n      title: 'Page A',\n      links: [{ slug: 'placeholder-link' }],\n      tags: ['primary'],\n    });\n    ws.set(page);\n\n    const tags = FoamTags.fromWorkspace(ws);\n    expect(tags.tags).toEqual(\n      new Map([\n        ['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],\n      ])\n    );\n\n    ws.delete(page.uri);\n    tags.update();\n\n    expect(tags.tags.size).toEqual(0);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/tags.ts",
    "content": "import { FoamWorkspace } from './workspace';\nimport { IDisposable } from '../common/lifecycle';\nimport { debounce } from 'lodash';\nimport { Emitter } from '../common/event';\nimport { Tag } from './note';\nimport { Location } from './location';\n\nexport class FoamTags implements IDisposable {\n  public readonly tags: Map<string, Location<Tag>[]> = new Map();\n\n  private onDidUpdateEmitter = new Emitter<void>();\n  onDidUpdate = this.onDidUpdateEmitter.event;\n\n  /**\n   * List of disposables to destroy with the tags\n   */\n  private disposables: IDisposable[] = [];\n\n  constructor(private readonly workspace: FoamWorkspace) {}\n\n  /**\n   * Computes all tags in the workspace and keep them up-to-date\n   *\n   * @param workspace the target workspace\n   * @param keepMonitoring whether to recompute the links when the workspace changes\n   * @param debounceFor how long to wait between change detection and tags update\n   * @returns the FoamTags\n   */\n  public static fromWorkspace(\n    workspace: FoamWorkspace,\n    keepMonitoring = false,\n    debounceFor = 0\n  ): FoamTags {\n    const tags = new FoamTags(workspace);\n    tags.update();\n\n    if (keepMonitoring) {\n      const updateTags =\n        debounceFor > 0\n          ? debounce(tags.update.bind(tags), 500)\n          : tags.update.bind(tags);\n      tags.disposables.push(\n        workspace.onDidAdd(updateTags),\n        workspace.onDidUpdate(updateTags),\n        workspace.onDidDelete(updateTags)\n      );\n    }\n    return tags;\n  }\n\n  update(): void {\n    this.tags.clear();\n    for (const resource of this.workspace.resources()) {\n      for (const tag of resource.tags) {\n        const tagLocations = this.tags.get(tag.label) ?? [];\n        tagLocations.push(Location.forObjectWithRange(resource.uri, tag));\n        this.tags.set(tag.label, tagLocations);\n      }\n    }\n    this.onDidUpdateEmitter.fire();\n  }\n\n  dispose(): void {\n    this.disposables.forEach(d => d.dispose());\n    this.disposables = [];\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/uri.test.ts",
    "content": "import { Logger } from '../utils/log';\nimport { asAbsoluteUri, URI } from './uri';\n\nLogger.setLevel('error');\n\ndescribe('Foam URI', () => {\n  describe('URI parsing', () => {\n    const base = URI.file('/path/to/file.md');\n    test.each([\n      ['https://www.google.com', URI.parse('https://www.google.com', 'file')],\n      ['/path/to/a/file.md', URI.parse('file:///path/to/a/file.md', 'file')],\n      [\n        '../relative/file.md',\n        URI.parse('file:///path/relative/file.md', 'file'),\n      ],\n      ['#section', base.with({ fragment: 'section' })],\n      [\n        '../relative/file.md#section',\n        URI.parse('file:///path/relative/file.md#section', 'file'),\n      ],\n    ])('URI Parsing (%s)', (input, exp) => {\n      const result = base.resolve(input);\n      expect(result.scheme).toEqual(exp.scheme);\n      expect(result.authority).toEqual(exp.authority);\n      expect(result.path).toEqual(exp.path);\n      expect(result.query).toEqual(exp.query);\n      expect(result.fragment).toEqual(exp.fragment);\n    });\n\n    it('normalizes the Windows drive letter to upper case', () => {\n      const upperCase = URI.parse('file:///C:/this/is/a/Path', 'file');\n      const lowerCase = URI.parse('file:///c:/this/is/a/Path', 'file');\n      expect(upperCase.path).toEqual('/C:/this/is/a/Path');\n      expect(lowerCase.path).toEqual('/C:/this/is/a/Path');\n      expect(upperCase.toFsPath()).toEqual('C:\\\\this\\\\is\\\\a\\\\Path');\n      expect(lowerCase.toFsPath()).toEqual('C:\\\\this\\\\is\\\\a\\\\Path');\n    });\n\n    it('consistently parses file paths', () => {\n      const win1 = URI.file('c:\\\\this\\\\is\\\\a\\\\path');\n      const win2 = URI.parse('c:\\\\this\\\\is\\\\a\\\\path', 'file');\n      expect(win1).toEqual(win2);\n\n      const unix1 = URI.file('/this/is/a/path');\n      const unix2 = URI.parse('/this/is/a/path', 'file');\n      expect(unix1).toEqual(unix2);\n    });\n\n    it('correctly parses file paths', () => {\n      const winUri = URI.file('c:\\\\this\\\\is\\\\a\\\\path');\n      const unixUri = URI.file('/this/is/a/path');\n      expect(winUri).toEqual(\n        new URI({\n          scheme: 'file',\n          path: '/C:/this/is/a/path',\n        })\n      );\n      expect(unixUri).toEqual(\n        new URI({\n          scheme: 'file',\n          path: '/this/is/a/path',\n        })\n      );\n    });\n  });\n\n  it('supports computing relative paths', () => {\n    expect(URI.file('/my/file.md').resolve('../hello.md')).toEqual(\n      URI.file('/hello.md')\n    );\n    expect(URI.file('/my/file.md').resolve('../hello')).toEqual(\n      URI.file('/hello.md')\n    );\n    expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(\n      URI.file('/hello.markdown')\n    );\n    expect(\n      URI.file('/path/to/a/note.md').resolve('../another-note.md')\n    ).toEqual(URI.file('/path/to/another-note.md'));\n    expect(\n      URI.file('/path/to/a/note.md').relativeTo(\n        URI.file('/path/to/another/note.md').getDirectory()\n      )\n    ).toEqual(URI.file('../a/note.md'));\n  });\n});\n\ndescribe('asAbsoluteUri', () => {\n  it('should throw if no workspace folder is found', () => {\n    expect(() => asAbsoluteUri(URI.file('relative/path'), [])).toThrow();\n  });\n  it('should return the given URI if already absolute', () => {\n    const uri = URI.file('/absolute/path');\n    expect(asAbsoluteUri(uri, [URI.file('/base')])).toEqual(uri);\n  });\n  describe('with relative URI', () => {\n    it('should return a URI relative if the given URI is relative and there is only one workspace folder', () => {\n      const uri = URI.file('relative/path');\n      const workspaceFolder = URI.file('/workspace/folder');\n      expect(asAbsoluteUri(uri, [workspaceFolder])).toEqual(\n        workspaceFolder.joinPath(uri.path)\n      );\n    });\n    it('should match the first folder with the same name as the first part of the URI', () => {\n      const uri = URI.file('folder2/file');\n      const workspaceFolder1 = URI.file('/absolute/path/folder1');\n      const workspaceFolder2 = URI.file('/absolute/path/folder2');\n      expect(asAbsoluteUri(uri, [workspaceFolder1, workspaceFolder2])).toEqual(\n        workspaceFolder2.joinPath('file')\n      );\n    });\n  });\n  it('should use the first folder if no matching folder is found', () => {\n    const uri = URI.file('folder3/file');\n    const workspaceFolder1 = URI.file('/absolute/path/folder1');\n    const workspaceFolder2 = URI.file('/absolute/path/folder2');\n    expect(asAbsoluteUri(uri, [workspaceFolder1, workspaceFolder2])).toEqual(\n      workspaceFolder1.joinPath(uri.path)\n    );\n  });\n  it('should use the first matching folder', () => {\n    const uri = URI.file('folder/file');\n    const workspaceFolder1 = URI.file('/absolute/path1');\n    const workspaceFolder2 = URI.file('/absolute/path2/folder');\n    const workspaceFolder3 = URI.file('/absolute/path3/folder');\n    expect(\n      asAbsoluteUri(uri, [workspaceFolder1, workspaceFolder2, workspaceFolder3])\n    ).toEqual(workspaceFolder2.joinPath('file'));\n  });\n\n  it('should return absolute path as-is via forPath when path does not start from base folder', () => {\n    // Documents the INTENTIONAL behavior after forceSubfolder removal:\n    // An absolute path like '/journal/file.md' that does NOT start with the\n    // workspace root path is returned as-is (not joined under the workspace root).\n    const result = asAbsoluteUri(URI.file('/journal/file.md'), [\n      URI.file('/workspace'),\n    ]);\n    expect(result.path).toBe('/journal/file.md');\n    expect(result.scheme).toBe('file');\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/uri.ts",
    "content": "// `URI` is mostly compatible with VSCode's `Uri`.\n// Having a Foam-specific URI object allows for easier maintenance of the API.\n// See https://github.com/foambubble/foam/pull/537 for more context.\n// Some code in this file comes from https://github.com/microsoft/vscode/main/src/vs/base/common/uri.ts\n// See LICENSE for details\n\nimport { CharCode } from '../common/charCode';\nimport { isNone } from '../utils';\nimport * as pathUtils from '../utils/path';\n\n/**\n * Uniform Resource Identifier (URI) http://tools.ietf.org/html/rfc3986.\n * This class is a simple parser which creates the basic component parts\n * (http://tools.ietf.org/html/rfc3986#section-3) with minimal validation\n * and encoding.\n *\n * ```txt\n *       foo://example.com:8042/over/there?name=ferret#nose\n *       \\_/   \\______________/\\_________/ \\_________/ \\__/\n *        |           |            |            |        |\n *     scheme     authority       path        query   fragment\n *        |   _____________________|__\n *       / \\ /                        \\\n *       urn:example:animal:ferret:nose\n * ```\n */\n\nconst _empty = '';\nconst _slash = '/';\nconst _regexp =\n  /^(([^:/?#]{2,}?):)?(\\/\\/([^/?#]*))?([^?#]*)(\\?([^#]*))?(#(.*))?/;\n\nexport class URI {\n  readonly scheme: string;\n  readonly authority: string;\n  readonly path: string;\n  readonly query: string;\n  readonly fragment: string;\n\n  constructor(from: Partial<URI> = {}) {\n    this.scheme = from.scheme ?? _empty;\n    this.authority = from.authority ?? _empty;\n    this.path = from.path ?? _empty; // We assume the path is already posix\n    this.query = from.query ?? _empty;\n    this.fragment = from.fragment ?? _empty;\n  }\n\n  /**\n   * Parses a string value into a URI object.\n   * @param value the string value of the URI\n   * @param defaultScheme the default scheme to use if none is provided in the value.\n   * - if a `string`, it will be used as the default scheme\n   * - if a `URI`, its scheme will be used as the default scheme\n   * - if `null`, no default scheme should be used (which forces `value` to have a scheme)\n   * @returns the parsed URI object\n   * @throws if no scheme is provided in value and no default scheme is given\n   */\n  static parse(value: string, defaultScheme: URI | string | null): URI {\n    const match = _regexp.exec(value);\n    if (!match) {\n      return new URI();\n    }\n    defaultScheme =\n      defaultScheme instanceof URI\n        ? defaultScheme.scheme\n        : (defaultScheme as string | null);\n    const scheme = match[2] || defaultScheme;\n    if (isNone(scheme)) {\n      throw new Error(`Invalid URI: The URI scheme is missing: ${value}`);\n    }\n    return new URI({\n      scheme,\n      authority: percentDecode(match[4] ?? _empty),\n      path: pathUtils.fromFsPath(percentDecode(match[5] ?? _empty))[0],\n      query: percentDecode(match[7] ?? _empty),\n      fragment: percentDecode(match[9] ?? _empty),\n    });\n  }\n\n  /**\n   * @deprecated Will not work with web extension. Use only for testing.\n   * @param value the path to turn into a URI\n   * @returns the file URI\n   */\n  static file(value: string): URI {\n    const [path, authority] = pathUtils.fromFsPath(value);\n    return new URI({ scheme: 'file', authority, path });\n  }\n\n  static placeholder(path: string): URI {\n    return new URI({ scheme: 'placeholder', path: path });\n  }\n\n  resolve(value: string | URI, isDirectory = false): URI {\n    const uri = value instanceof URI ? value : URI.parse(value, 'file');\n    if (!uri.isAbsolute()) {\n      if (uri.scheme === 'file' || uri.scheme === 'placeholder') {\n        let newUri = this.with({ fragment: uri.fragment });\n        if (uri.path) {\n          newUri = (isDirectory ? newUri : newUri.getDirectory())\n            .joinPath(uri.path)\n            .changeExtension('', this.getExtension());\n        }\n        return newUri;\n      }\n    }\n    return uri;\n  }\n\n  isAbsolute(): boolean {\n    return pathUtils.isAbsolute(this.path);\n  }\n\n  getDirectory(): URI {\n    const path = pathUtils.getDirectory(this.path);\n    return new URI({ ...this, path });\n  }\n\n  getBasename(): string {\n    return pathUtils.getBasename(this.path);\n  }\n\n  getName(): string {\n    return pathUtils.getName(this.path);\n  }\n\n  getExtension(): string {\n    return pathUtils.getExtension(this.path);\n  }\n\n  changeExtension(from: string, to: string): URI {\n    const path = pathUtils.changeExtension(this.path, from, to);\n    return new URI({ ...this, path });\n  }\n\n  joinPath(...paths: string[]) {\n    const path = pathUtils.joinPath(this.path, ...paths);\n    return new URI({ ...this, path });\n  }\n\n  relativeTo(uri: URI) {\n    const path = pathUtils.relativeTo(this.path, uri.path);\n    return new URI({ ...this, path });\n  }\n\n  /**\n   * Creates a new URI with the specified changes.\n   * Note that this does not validate the resulting URI, e.g. you can\n   * set the path to a relative path.\n   * If you want to ensure that the path is properly formatted, use `forPath` instead.\n   *\n   * @param change an object that describes the desired changes to the URI.\n   * @returns a new URI instance with the updated fields\n   */\n  with(change: {\n    scheme?: string;\n    authority?: string;\n    path?: string;\n    query?: string;\n    fragment?: string;\n  }): URI {\n    return new URI({\n      scheme: change.scheme ?? this.scheme,\n      authority: change.authority ?? this.authority,\n      path: change.path ?? this.path,\n      query: change.query ?? this.query,\n      fragment: change.fragment ?? this.fragment,\n    });\n  }\n\n  /**\n   * Creates a new URI with the specified path.\n   * The difference between `with({ path })` and `forPath(path)` is that\n   * this function will ensure that the path is properly formatted (e.g. starting with a `/`)\n   * whereas `with` will take the path \"as is\".\n   *\n   * @param path the new path\n   * @returns a new URI instance with the updated path\n   */\n  forPath(path: string): URI {\n    const formattedPath = pathUtils.fromFsPath(percentDecode(path))[0];\n    return new URI({ ...this, path: formattedPath });\n  }\n\n  /**\n   * Returns a URI without the fragment and query information\n   */\n  asPlain(): URI {\n    return new URI({ ...this, fragment: '', query: '' });\n  }\n\n  isPlaceholder(): boolean {\n    return this.scheme === 'placeholder';\n  }\n\n  toFsPath() {\n    return pathUtils.toFsPath(\n      this.path,\n      this.scheme === 'file' ? this.authority : ''\n    );\n  }\n\n  toString(): string {\n    return encode(this, false);\n  }\n\n  isMarkdown(): boolean {\n    const ext = this.getExtension();\n    return ext === '.md' || ext === '.markdown';\n  }\n\n  isEqual(uri: URI): boolean {\n    return (\n      this.authority === uri.authority &&\n      this.scheme === uri.scheme &&\n      this.path === uri.path &&\n      this.fragment === uri.fragment &&\n      this.query === uri.query\n    );\n  }\n}\n\n// --- encode / decode\n\nfunction decodeURIComponentGraceful(str: string): string {\n  try {\n    return decodeURIComponent(str);\n  } catch {\n    if (str.length > 3) {\n      return str.substr(0, 3) + decodeURIComponentGraceful(str.substr(3));\n    } else {\n      return str;\n    }\n  }\n}\n\nconst _rEncodedAsHex = /(%[0-9A-Za-z][0-9A-Za-z])+/g;\n\nfunction percentDecode(str: string): string {\n  if (!str.match(_rEncodedAsHex)) {\n    return str;\n  }\n  return str.replace(_rEncodedAsHex, match =>\n    decodeURIComponentGraceful(match)\n  );\n}\n\n/**\n * Create the external version of a uri\n */\nfunction encode(uri: URI, skipEncoding: boolean): string {\n  const encoder = !skipEncoding\n    ? encodeURIComponentFast\n    : encodeURIComponentMinimal;\n\n  let res = '';\n  let { scheme, authority, path, query, fragment } = uri;\n  if (scheme) {\n    res += scheme;\n    res += ':';\n  }\n  if (authority || scheme === 'file') {\n    res += _slash;\n    res += _slash;\n  }\n  if (authority) {\n    let idx = authority.indexOf('@');\n    if (idx !== -1) {\n      // <user>@<auth>\n      const userinfo = authority.substr(0, idx);\n      authority = authority.substr(idx + 1);\n      idx = userinfo.indexOf(':');\n      if (idx === -1) {\n        res += encoder(userinfo, false);\n      } else {\n        // <user>:<pass>@<auth>\n        res += encoder(userinfo.substr(0, idx), false);\n        res += ':';\n        res += encoder(userinfo.substr(idx + 1), false);\n      }\n      res += '@';\n    }\n    authority = authority.toLowerCase();\n    idx = authority.indexOf(':');\n    if (idx === -1) {\n      res += encoder(authority, false);\n    } else {\n      // <auth>:<port>\n      res += encoder(authority.substr(0, idx), false);\n      res += authority.substr(idx);\n    }\n  }\n  if (path) {\n    // upper-case windows drive letters in /c:/fff or c:/fff\n    if (\n      path.length >= 3 &&\n      path.charCodeAt(0) === CharCode.Slash &&\n      path.charCodeAt(2) === CharCode.Colon\n    ) {\n      const code = path.charCodeAt(1);\n      if (code >= CharCode.a && code <= CharCode.z) {\n        path = `/${String.fromCharCode(code - 32)}:${path.substr(3)}`; // \"/C:\".length === 3\n      }\n    } else if (path.length >= 2 && path.charCodeAt(1) === CharCode.Colon) {\n      const code = path.charCodeAt(0);\n      if (code >= CharCode.a && code <= CharCode.z) {\n        path = `${String.fromCharCode(code - 32)}:${path.substr(2)}`; // \"/C:\".length === 3\n      }\n    }\n    // encode the rest of the path\n    res += encoder(path, true);\n  }\n  if (query) {\n    res += '?';\n    res += encoder(query, false);\n  }\n  if (fragment) {\n    res += '#';\n    res += !skipEncoding ? encodeURIComponentFast(fragment, false) : fragment;\n  }\n  return res;\n}\n\n// reserved characters: https://tools.ietf.org/html/rfc3986#section-2.2\nconst encodeTable: { [ch: number]: string } = {\n  [CharCode.Colon]: '%3A', // gen-delims\n  [CharCode.Slash]: '%2F',\n  [CharCode.QuestionMark]: '%3F',\n  [CharCode.Hash]: '%23',\n  [CharCode.OpenSquareBracket]: '%5B',\n  [CharCode.CloseSquareBracket]: '%5D',\n  [CharCode.AtSign]: '%40',\n\n  [CharCode.ExclamationMark]: '%21', // sub-delims\n  [CharCode.DollarSign]: '%24',\n  [CharCode.Ampersand]: '%26',\n  [CharCode.SingleQuote]: '%27',\n  [CharCode.OpenParen]: '%28',\n  [CharCode.CloseParen]: '%29',\n  [CharCode.Asterisk]: '%2A',\n  [CharCode.Plus]: '%2B',\n  [CharCode.Comma]: '%2C',\n  [CharCode.Semicolon]: '%3B',\n  [CharCode.Equals]: '%3D',\n\n  [CharCode.Space]: '%20',\n};\n\nfunction encodeURIComponentFast(\n  uriComponent: string,\n  allowSlash: boolean\n): string {\n  let res: string | undefined = undefined;\n  let nativeEncodePos = -1;\n\n  for (let pos = 0; pos < uriComponent.length; pos++) {\n    const code = uriComponent.charCodeAt(pos);\n\n    // unreserved characters: https://tools.ietf.org/html/rfc3986#section-2.3\n    if (\n      (code >= CharCode.a && code <= CharCode.z) ||\n      (code >= CharCode.A && code <= CharCode.Z) ||\n      (code >= CharCode.Digit0 && code <= CharCode.Digit9) ||\n      code === CharCode.Dash ||\n      code === CharCode.Period ||\n      code === CharCode.Underline ||\n      code === CharCode.Tilde ||\n      (allowSlash && code === CharCode.Slash)\n    ) {\n      // check if we are delaying native encode\n      if (nativeEncodePos !== -1) {\n        res += encodeURIComponent(uriComponent.substring(nativeEncodePos, pos));\n        nativeEncodePos = -1;\n      }\n      // check if we write into a new string (by default we try to return the param)\n      if (res !== undefined) {\n        res += uriComponent.charAt(pos);\n      }\n    } else {\n      // encoding needed, we need to allocate a new string\n      if (res === undefined) {\n        res = uriComponent.substr(0, pos);\n      }\n\n      // check with default table first\n      const escaped = encodeTable[code];\n      if (escaped !== undefined) {\n        // check if we are delaying native encode\n        if (nativeEncodePos !== -1) {\n          res += encodeURIComponent(\n            uriComponent.substring(nativeEncodePos, pos)\n          );\n          nativeEncodePos = -1;\n        }\n\n        // append escaped variant to result\n        res += escaped;\n      } else if (nativeEncodePos === -1) {\n        // use native encode only when needed\n        nativeEncodePos = pos;\n      }\n    }\n  }\n\n  if (nativeEncodePos !== -1) {\n    res += encodeURIComponent(uriComponent.substring(nativeEncodePos));\n  }\n\n  return res !== undefined ? res : uriComponent;\n}\n\nfunction encodeURIComponentMinimal(path: string): string {\n  let res: string | undefined = undefined;\n  for (let pos = 0; pos < path.length; pos++) {\n    const code = path.charCodeAt(pos);\n    if (code === CharCode.Hash || code === CharCode.QuestionMark) {\n      if (res === undefined) {\n        res = path.substr(0, pos);\n      }\n      res += encodeTable[code];\n    } else {\n      if (res !== undefined) {\n        res += path[pos];\n      }\n    }\n  }\n  return res !== undefined ? res : path;\n}\n\n/**\n * Turns a relative URI into an absolute URI given a collection of base folders.\n * In case of multiple matches it returns the first one.\n *\n * @see {@link pathUtils.asAbsolutePaths|path.asAbsolutePath}\n *\n * @param uri the uri to evaluate\n * @param baseFolders the base folders to use\n * @returns an absolute uri\n */\nexport function asAbsoluteUri(\n  uriOrPath: URI | string,\n  baseFolders: URI[]\n): URI {\n  if (baseFolders.length === 0) {\n    throw new Error('At least one base folder needed to compute URI');\n  }\n  const path = uriOrPath instanceof URI ? uriOrPath.path : uriOrPath;\n\n  // Check if this is already a POSIX absolute path or Windows drive path\n  if (path.startsWith('/') || /^[a-zA-Z]:/.test(path)) {\n    return baseFolders[0].forPath(path);\n  }\n  let tokens = path.split('/');\n  while (tokens[0].trim() === '') {\n    tokens.shift();\n  }\n  const firstDir = tokens[0];\n  if (baseFolders.length > 1) {\n    for (const folder of baseFolders) {\n      const lastDir = folder.path.split('/').pop();\n      if (lastDir === firstDir) {\n        tokens = tokens.slice(1);\n        return folder.joinPath(...tokens);\n      }\n    }\n  }\n  return baseFolders[0].joinPath(...tokens);\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/workspace.test.ts",
    "content": "import { FoamWorkspace } from './workspace';\nimport { Logger } from '../utils/log';\nimport { URI } from './uri';\nimport { createTestNote, createTestWorkspace } from '../../test/test-utils';\n\nLogger.setLevel('error');\n\ndescribe('Workspace resources', () => {\n  it('should allow adding notes to the workspace', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/page-a.md' }));\n    ws.set(createTestNote({ uri: '/page-b.md' }));\n    ws.set(createTestNote({ uri: '/page-c.md' }));\n\n    expect(\n      ws\n        .list()\n        .map(n => n.uri.path)\n        .sort()\n    ).toEqual(['/page-a.md', '/page-b.md', '/page-c.md']);\n  });\n\n  it('should includes all notes when listing resources', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/page-a.md' }));\n    ws.set(createTestNote({ uri: '/file.pdf' }));\n\n    expect(\n      ws\n        .list()\n        .map(n => n.uri.path)\n        .sort()\n    ).toEqual(['/file.pdf', '/page-a.md']);\n  });\n\n  it('should fail when trying to get a non-existing note', () => {\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n    });\n    const ws = createTestWorkspace();\n    ws.set(noteA);\n\n    const uri = URI.file('/path/to/another/page-b.md');\n    expect(ws.exists(uri)).toBeFalsy();\n    expect(ws.find(uri)).toBeNull();\n    expect(() => ws.get(uri)).toThrow();\n  });\n\n  it('should work with a resource named like a JS prototype property', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({ uri: '/somewhere/constructor.md' });\n    ws.set(noteA);\n    expect(ws.list()).toEqual([noteA]);\n  });\n\n  it('should not return files with same suffix when listing by ID - #851', () => {\n    const ws = createTestWorkspace()\n      .set(createTestNote({ uri: 'test-file.md' }))\n      .set(createTestNote({ uri: 'file.md' }));\n    expect(ws.listByIdentifier('file').length).toEqual(1);\n  });\n\n  it('should support dendron-style names', () => {\n    const ws = createTestWorkspace()\n      .set(createTestNote({ uri: 'note.pdf' }))\n      .set(createTestNote({ uri: 'note.md' }))\n      .set(createTestNote({ uri: 'note.yo.md' }))\n      .set(createTestNote({ uri: 'note2.md' }));\n    for (const [reference, path] of [\n      ['note', '/note.md'],\n      ['note.md', '/note.md'],\n      ['note.yo', '/note.yo.md'],\n      ['note.yo.md', '/note.yo.md'],\n      ['note.pdf', '/note.pdf'],\n      ['note2', '/note2.md'],\n    ]) {\n      expect(ws.listByIdentifier(reference)[0].uri.path).toEqual(path);\n      expect(ws.find(reference).uri.path).toEqual(path);\n    }\n  });\n\n  it('should keep the fragment information when finding a resource', () => {\n    const ws = createTestWorkspace()\n      .set(createTestNote({ uri: 'test-file.md' }))\n      .set(createTestNote({ uri: 'file.md' }));\n\n    const res = ws.find('test-file#my-section');\n    expect(res.uri.fragment).toEqual('my-section');\n  });\n\n  it('should find absolute files even when no basedir is provided', () => {\n    const noteA = createTestNote({ uri: '/a/path/to/file.md' });\n    const ws = createTestWorkspace().set(noteA);\n\n    expect(ws.find('/a/path/to/file.md').uri.path).toEqual(noteA.uri.path);\n  });\n});\n\ndescribe('Identifier computation', () => {\n  it('should compute the minimum identifier to resolve a name clash', () => {\n    const first = createTestNote({\n      uri: '/path/to/page-a.md',\n    });\n    const second = createTestNote({\n      uri: '/another/way/for/page-a.md',\n    });\n    const third = createTestNote({\n      uri: '/another/path/for/page-a.md',\n    });\n    const ws = new FoamWorkspace([], '.md').set(first).set(second).set(third);\n\n    expect(ws.getIdentifier(first.uri)).toEqual('to/page-a');\n    expect(ws.getIdentifier(second.uri)).toEqual('way/for/page-a');\n    expect(ws.getIdentifier(third.uri)).toEqual('path/for/page-a');\n  });\n\n  it('should support sections in identifier computation', () => {\n    const first = createTestNote({\n      uri: '/path/to/page-a.md',\n    });\n    const second = createTestNote({\n      uri: '/another/way/for/page-a.md',\n    });\n    const third = createTestNote({\n      uri: '/another/path/for/page-a.md',\n    });\n    const ws = new FoamWorkspace([], '.md').set(first).set(second).set(third);\n\n    expect(\n      ws.getIdentifier(first.uri.with({ fragment: 'section name' }))\n    ).toEqual('to/page-a#section name');\n  });\n\n  const needle = '/project/car/todo';\n\n  test.each([\n    [['/project/home/todo', '/other/todo', '/something/else'], 'car/todo'],\n    [['/family/car/todo', '/other/todo'], 'project/car/todo'],\n    [[], 'todo'],\n  ])('should find shortest identifier', (haystack, id) => {\n    expect(FoamWorkspace.getShortestIdentifier(needle, haystack)).toEqual(id);\n  });\n\n  it('should ignore same string in haystack', () => {\n    const haystack = [\n      needle,\n      '/project/home/todo',\n      '/other/todo',\n      '/something/else',\n    ];\n    const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);\n    expect(identifier).toEqual('car/todo');\n  });\n\n  it('should return the best guess when no solution is possible', () => {\n    /**\n     * In this case there is no way to uniquely identify the element,\n     * our fallback is to just return the \"least wrong\" result, basically\n     * a full identifier\n     * This is an edge case that should never happen in a real repo\n     */\n    const haystack = [\n      '/parent/' + needle,\n      '/project/home/todo',\n      '/other/todo',\n      '/something/else',\n    ];\n    const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);\n    expect(identifier).toEqual('project/car/todo');\n  });\n\n  it('should ignore elements from the exclude list', () => {\n    const workspace = new FoamWorkspace([], '.md');\n    const noteA = createTestNote({ uri: '/path/to/note-a.md' });\n    const noteB = createTestNote({ uri: '/path/to/note-b.md' });\n    const noteC = createTestNote({ uri: '/path/to/note-c.md' });\n    const noteD = createTestNote({ uri: '/path/to/note-d.md' });\n    const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });\n\n    workspace.set(noteA).set(noteB).set(noteC).set(noteD);\n    expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');\n    expect(\n      workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])\n    ).toEqual('note-a');\n  });\n\n  it('should handle case-sensitive filenames correctly (#1303)', () => {\n    const workspace = new FoamWorkspace([], '.md');\n    const noteUppercase = createTestNote({ uri: '/a/Note.md' });\n    const noteLowercase = createTestNote({ uri: '/b/note.md' });\n\n    workspace.set(noteUppercase).set(noteLowercase);\n\n    // Should find exact case matches\n    expect(workspace.listByIdentifier('Note').length).toEqual(1);\n    expect(workspace.listByIdentifier('Note')[0].uri.path).toEqual(\n      '/a/Note.md'\n    );\n\n    expect(workspace.listByIdentifier('note').length).toEqual(1);\n    expect(workspace.listByIdentifier('note')[0].uri.path).toEqual(\n      '/b/note.md'\n    );\n\n    // Should not treat them as the same identifier\n    expect(workspace.listByIdentifier('Note')[0]).not.toEqual(\n      workspace.listByIdentifier('note')[0]\n    );\n  });\n\n  it('should generate correct identifiers for case-sensitive files', () => {\n    const workspace = new FoamWorkspace([], '.md');\n    const noteUppercase = createTestNote({ uri: '/a/Note.md' });\n    const noteLowercase = createTestNote({ uri: '/b/note.md' });\n\n    workspace.set(noteUppercase).set(noteLowercase);\n\n    // Each should have a unique identifier without directory disambiguation\n    // since they differ by case, they are not considered conflicting\n    expect(workspace.getIdentifier(noteUppercase.uri)).toEqual('Note');\n    expect(workspace.getIdentifier(noteLowercase.uri)).toEqual('note');\n  });\n});\n\ndescribe('find in multi-root workspaces', () => {\n  it('should find a resource that lives in root[1] when not found in root[0]', () => {\n    const ws = new FoamWorkspace([\n      URI.file('/workspace1'),\n      URI.file('/workspace2'),\n    ]);\n    const note = createTestNote({ uri: '/workspace2/shared/file.md' });\n    ws.set(note);\n\n    const found = ws.find('/shared/file.md');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace2/shared/file.md');\n  });\n\n  it('should find root[0] resource first when the same relative path exists in both roots', () => {\n    const ws = new FoamWorkspace([\n      URI.file('/workspace1'),\n      URI.file('/workspace2'),\n    ]);\n    const noteA = createTestNote({ uri: '/workspace1/shared/file.md' });\n    const noteB = createTestNote({ uri: '/workspace2/shared/file.md' });\n    ws.set(noteA).set(noteB);\n\n    const found = ws.find('/shared/file.md');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace1/shared/file.md');\n  });\n\n  it('should find via workspace-relative path in a 3-root workspace when resource is in root[2]', () => {\n    const ws = new FoamWorkspace([\n      URI.file('/workspace1'),\n      URI.file('/workspace2'),\n      URI.file('/workspace3'),\n    ]);\n    const note = createTestNote({ uri: '/workspace3/notes/file.md' });\n    ws.set(note);\n\n    const found = ws.find('/notes/file.md');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace3/notes/file.md');\n  });\n});\n\ndescribe('resolveUri', () => {\n  const root = URI.file('/workspace');\n\n  it('should return an already-absolute path under the root as-is (case 1)', () => {\n    const ws = new FoamWorkspace([root]);\n    const result = ws.resolveUri('/workspace/journal/file.md');\n    expect(result.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should resolve a workspace-relative absolute path under the root (case 2)', () => {\n    const ws = new FoamWorkspace([root]);\n    const result = ws.resolveUri('/journal/file.md');\n    expect(result.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should resolve a relative path against roots[0] when no relativeTo is given (case 3)', () => {\n    const ws = new FoamWorkspace([root]);\n    const result = ws.resolveUri('journal/file.md');\n    expect(result.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should resolve a relative path against relativeTo when provided (case 3)', () => {\n    const ws = new FoamWorkspace([root]);\n    const base = URI.file('/workspace/subdir/note.md');\n    const result = ws.resolveUri('../other/file.md', base);\n    expect(result.path).toBe('/workspace/other/file.md');\n  });\n\n  it('should return an absolute path as URI.file when roots is empty', () => {\n    const ws = new FoamWorkspace([]);\n    const result = ws.resolveUri('/some/absolute/file.md');\n    expect(result.path).toBe('/some/absolute/file.md');\n  });\n\n  it('should handle the root path itself as under-root (case 1)', () => {\n    const ws = new FoamWorkspace([root]);\n    const result = ws.resolveUri('/workspace');\n    expect(result.path).toBe('/workspace');\n  });\n\n  it('should use first root when multiple roots exist and path is workspace-relative (case 2)', () => {\n    const root2 = URI.file('/other-root');\n    const ws = new FoamWorkspace([root, root2]);\n    const result = ws.resolveUri('/journal/file.md');\n    expect(result.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should detect a path already under root[1] as under-root and return it as-is', () => {\n    const root2 = URI.file('/workspace2');\n    const ws = new FoamWorkspace([root, root2]);\n    const result = ws.resolveUri('/workspace2/shared/file.md');\n    // Must NOT become '/workspace/workspace2/shared/file.md'\n    expect(result.path).toBe('/workspace2/shared/file.md');\n  });\n\n  describe('Windows drive paths', () => {\n    it('should recognize a backslash drive path already under the root as-is (case 1)', () => {\n      const winRoot = URI.file('C:\\\\workspace');\n      const ws = new FoamWorkspace([winRoot]);\n      // Raw backslash path: must be normalized before comparison, not doubled\n      const result = ws.resolveUri('C:\\\\workspace\\\\journal\\\\file.md');\n      expect(result.path).toBe('/C:/workspace/journal/file.md');\n    });\n\n    it('should not double a forward-slash drive path already under the root (case 1)', () => {\n      const winRoot = URI.file('C:\\\\workspace');\n      const ws = new FoamWorkspace([winRoot]);\n      const result = ws.resolveUri('/C:/workspace/journal/file.md');\n      expect(result.path).toBe('/C:/workspace/journal/file.md');\n    });\n  });\n});\n\ndescribe('find with workspace-relative absolute paths', () => {\n  it('should find a resource stored at a real absolute path via a workspace-relative path', () => {\n    const root = URI.file('/workspace');\n    const ws = new FoamWorkspace([root]);\n    const note = createTestNote({ uri: '/workspace/journal/file.md' });\n    ws.set(note);\n\n    // workspace-relative absolute path → should resolve to /workspace/journal/file.md\n    const found = ws.find('/journal/file.md');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should find with .md extension appended to workspace-relative path', () => {\n    const root = URI.file('/workspace');\n    const ws = new FoamWorkspace([root]);\n    const note = createTestNote({ uri: '/workspace/journal/file.md' });\n    ws.set(note);\n\n    const found = ws.find('/journal/file');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace/journal/file.md');\n  });\n\n  it('should still find an already-absolute filesystem path directly', () => {\n    const root = URI.file('/workspace');\n    const ws = new FoamWorkspace([root]);\n    const note = createTestNote({ uri: '/workspace/journal/file.md' });\n    ws.set(note);\n\n    const found = ws.find('/workspace/journal/file.md');\n    expect(found).not.toBeNull();\n    expect(found.uri.path).toBe('/workspace/journal/file.md');\n  });\n});\n\ndescribe('Directory index', () => {\n  it('should resolve a directory to its index file', () => {\n    const ws = createTestWorkspace();\n    const index = createTestNote({ uri: '/foo/bar/index.md' });\n    ws.set(index);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(index);\n  });\n\n  it('should resolve a directory to its README file', () => {\n    const ws = createTestWorkspace();\n    const readme = createTestNote({ uri: '/foo/bar/README.md' });\n    ws.set(readme);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(readme);\n  });\n\n  it('should prefer index over README regardless of insertion order - index first', () => {\n    const ws = createTestWorkspace();\n    const index = createTestNote({ uri: '/foo/bar/index.md' });\n    const readme = createTestNote({ uri: '/foo/bar/README.md' });\n    ws.set(index).set(readme);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(index);\n  });\n\n  it('should prefer index over README regardless of insertion order - README first', () => {\n    const ws = createTestWorkspace();\n    const index = createTestNote({ uri: '/foo/bar/index.md' });\n    const readme = createTestNote({ uri: '/foo/bar/README.md' });\n    ws.set(readme).set(index);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(index);\n  });\n\n  it('should promote README when index is deleted', () => {\n    const ws = createTestWorkspace();\n    const index = createTestNote({ uri: '/foo/bar/index.md' });\n    const readme = createTestNote({ uri: '/foo/bar/README.md' });\n    ws.set(index).set(readme);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(index);\n    ws.delete(index.uri);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(readme);\n  });\n\n  it('should return null when the only index file is deleted', () => {\n    const ws = createTestWorkspace();\n    const index = createTestNote({ uri: '/foo/bar/index.md' });\n    ws.set(index);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(index);\n    ws.delete(index.uri);\n    expect(ws.findByDirectory('/foo/bar')).toBeNull();\n  });\n\n  it('should return null for a directory with no index files', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/foo/bar/page.md' }));\n    expect(ws.findByDirectory('/foo/bar')).toBeNull();\n  });\n\n  it('should not treat regular files as index files', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/foo/bar/page.md' }));\n    ws.set(createTestNote({ uri: '/foo/bar/notes.md' }));\n    expect(ws.findByDirectory('/foo/bar')).toBeNull();\n  });\n\n  it('should not register non-note resources (attachments, images) as directory index', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/foo/bar/index.png', type: 'image' }));\n    ws.set(createTestNote({ uri: '/foo/bar/README.pdf', type: 'attachment' }));\n    expect(ws.findByDirectory('/foo/bar')).toBeNull();\n  });\n\n  it('should track index files independently per directory', () => {\n    const ws = createTestWorkspace();\n    const indexA = createTestNote({ uri: '/foo/bar/index.md' });\n    const indexB = createTestNote({ uri: '/foo/baz/index.md' });\n    ws.set(indexA).set(indexB);\n    expect(ws.findByDirectory('/foo/bar')).toEqual(indexA);\n    expect(ws.findByDirectory('/foo/baz')).toEqual(indexB);\n  });\n\n  it('should clear directory index on workspace clear', () => {\n    const ws = createTestWorkspace();\n    ws.set(createTestNote({ uri: '/foo/bar/index.md' }));\n    ws.clear();\n    expect(ws.findByDirectory('/foo/bar')).toBeNull();\n  });\n\n  describe('getDirectoryIdentifier', () => {\n    it('should return null for a non-index file', () => {\n      const ws = createTestWorkspace();\n      const note = createTestNote({ uri: '/foo/bar/page.md' });\n      ws.set(note);\n      expect(ws.getDirectoryIdentifier(note.uri)).toBeNull();\n    });\n\n    it('should return the directory name when unambiguous', () => {\n      const ws = createTestWorkspace();\n      const index = createTestNote({ uri: '/foo/bar/index.md' });\n      ws.set(index);\n      expect(ws.getDirectoryIdentifier(index.uri)).toBe('bar');\n    });\n\n    it('should return a more specific path when directory name is ambiguous', () => {\n      const ws = createTestWorkspace();\n      const fooIndex = createTestNote({ uri: '/foo/bar/index.md' });\n      const zooIndex = createTestNote({ uri: '/zoo/bar/index.md' });\n      ws.set(fooIndex).set(zooIndex);\n      expect(ws.getDirectoryIdentifier(fooIndex.uri)).toBe('foo/bar');\n      expect(ws.getDirectoryIdentifier(zooIndex.uri)).toBe('zoo/bar');\n    });\n\n    it('should return null for a README.md when index.md owns the directory', () => {\n      const ws = createTestWorkspace();\n      const index = createTestNote({ uri: '/foo/bar/index.md' });\n      const readme = createTestNote({ uri: '/foo/bar/README.md' });\n      ws.set(index).set(readme);\n      expect(ws.getDirectoryIdentifier(readme.uri)).toBeNull();\n      expect(ws.getDirectoryIdentifier(index.uri)).toBe('bar');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/model/workspace.ts",
    "content": "import { Resource, ResourceLink } from './note';\nimport { URI } from './uri';\nimport { isAbsolute, getExtension, changeExtension } from '../utils/path';\nimport { isSome } from '../utils';\nimport { Emitter } from '../common/event';\nimport { ResourceProvider } from './provider';\nimport { IDisposable } from '../common/lifecycle';\nimport { IDataStore } from '../services/datastore';\nimport TrieMap from 'mnemonist/trie-map';\n\nexport class FoamWorkspace implements IDisposable {\n  private onDidAddEmitter = new Emitter<Resource>();\n  private onDidUpdateEmitter = new Emitter<{ old: Resource; new: Resource }>();\n  private onDidDeleteEmitter = new Emitter<Resource>();\n  onDidAdd = this.onDidAddEmitter.event;\n  onDidUpdate = this.onDidUpdateEmitter.event;\n  onDidDelete = this.onDidDeleteEmitter.event;\n\n  private providers: ResourceProvider[] = [];\n\n  /**\n   * Resources by path\n   */\n  private _resources: TrieMap<string, Resource> = new TrieMap();\n\n  /**\n   * Maps a normalized directory path to the URI of its directory index file owner.\n   * Only index and README files (with any configured note extension) are registered here.\n   * Priority: index > README. Maintained independently of the main trie.\n   */\n  private _directoryIndex: Map<string, URI> = new Map();\n\n  /** Basenames (without extension, lowercase) that qualify as directory index files, in priority order */\n  private static readonly DIRECTORY_INDEX_NAMES = ['index', 'readme'];\n\n  /**\n   * The root URIs of this workspace, in priority order.\n   * Used for resolving workspace-relative paths.\n   * First root is always used when a path must be resolved to exactly one root.\n   */\n  readonly roots: URI[];\n\n  /**\n   * @param roots The root URIs of the workspace (e.g. VS Code workspace folders)\n   * @param defaultExtension The default extension for notes in this workspace (e.g. `.md`)\n   */\n  constructor(roots: URI[] = [], public defaultExtension: string = '.md') {\n    this.roots = roots;\n  }\n\n  registerProvider(provider: ResourceProvider) {\n    this.providers.push(provider);\n  }\n\n  /**\n   * Resolves a path string to an absolute URI within this workspace.\n   *\n   * Resolution rules (in order):\n   * 1. Filesystem-absolute path already under a workspace root → returned as-is\n   * 2. Workspace-relative absolute path (starts with '/' but not under any root) →\n   *    resolved as roots[0].joinPath(path)\n   * 3. Relative path → resolved relative to `relativeTo` if provided, otherwise roots[0]\n   *\n   * When roots is empty, absolute paths are returned via URI.file() and relative paths\n   * require a `relativeTo` base.\n   */\n  resolveUri(filepath: string, relativeTo?: URI): URI {\n    const isDrivePath = /^[a-zA-Z]:/.test(filepath);\n    const isAbsolutePath = filepath.startsWith('/') || isDrivePath;\n\n    if (isAbsolutePath) {\n      if (this.roots.length === 0) {\n        return URI.file(filepath);\n      }\n      // Normalize Windows drive paths to POSIX form (/C:/...) before comparison,\n      // since root.path is always in POSIX form. Raw backslash paths like\n      // C:\\workspace\\note.md would never match root.path otherwise.\n      const normalizedFilepath = isDrivePath\n        ? URI.file(filepath).path\n        : filepath;\n      const isUnderRoot = this.roots.some(root =>\n        isDrivePath\n          ? normalizedFilepath\n              .toLowerCase()\n              .startsWith(root.path.toLowerCase() + '/') ||\n            normalizedFilepath.toLowerCase() === root.path.toLowerCase()\n          : normalizedFilepath.startsWith(root.path + '/') ||\n            normalizedFilepath === root.path\n      );\n      if (isUnderRoot) {\n        return this.roots[0].forPath(normalizedFilepath); // case 1: already absolute under root\n      }\n      return this.roots[0].joinPath(normalizedFilepath); // case 2: workspace-relative absolute\n    }\n\n    // case 3: relative path\n    if (relativeTo) {\n      // relativeTo is a file URI — resolve against its parent directory\n      return relativeTo.getDirectory().joinPath(filepath);\n    }\n    if (this.roots.length === 0) {\n      throw new Error(\n        'Cannot resolve relative path without a relativeTo URI or workspace roots'\n      );\n    }\n    // roots[0] is a directory — join directly\n    return this.roots[0].joinPath(filepath);\n  }\n\n  set(resource: Resource) {\n    const old = this.find(resource.uri);\n\n    // store resource\n    this._resources.set(this.getTrieIdentifier(resource.uri.path), resource);\n    this._registerDirectoryIndex(resource);\n\n    isSome(old)\n      ? this.onDidUpdateEmitter.fire({ old: old, new: resource })\n      : this.onDidAddEmitter.fire(resource);\n    return this;\n  }\n\n  delete(uri: URI) {\n    const deleted = this._resources.get(this.getTrieIdentifier(uri));\n    this._resources.delete(this.getTrieIdentifier(uri));\n    this._unregisterDirectoryIndex(uri);\n\n    isSome(deleted) && this.onDidDeleteEmitter.fire(deleted);\n    return deleted ?? null;\n  }\n\n  clear() {\n    const resources = Array.from(this._resources.values());\n    this._resources.clear();\n    this._directoryIndex.clear();\n\n    // Fire delete events for all resources\n    resources.forEach(resource => {\n      this.onDidDeleteEmitter.fire(resource);\n    });\n  }\n\n  /**\n   * Returns the priority index of a URI as a directory index file (lower = higher priority).\n   * Returns -1 if the URI is not a directory index file.\n   */\n  private _directoryIndexPriority(uri: URI): number {\n    const ext = uri.getExtension();\n    if (!ext) return -1;\n    const name = uri.getBasename().slice(0, -ext.length).toLowerCase();\n    return FoamWorkspace.DIRECTORY_INDEX_NAMES.indexOf(name);\n  }\n\n  private _registerDirectoryIndex(resource: Resource): void {\n    if (resource.type !== 'note') return;\n    const priority = this._directoryIndexPriority(resource.uri);\n    if (priority === -1) return;\n\n    const dirPath = normalize(resource.uri.getDirectory().path);\n    const currentOwnerUri = this._directoryIndex.get(dirPath);\n    if (currentOwnerUri) {\n      const currentPriority = this._directoryIndexPriority(currentOwnerUri);\n      if (priority >= currentPriority) return; // current owner has equal or higher priority\n    }\n    this._directoryIndex.set(dirPath, resource.uri);\n  }\n\n  private _unregisterDirectoryIndex(uri: URI): void {\n    const priority = this._directoryIndexPriority(uri);\n    if (priority === -1) return;\n\n    const dirPath = normalize(uri.getDirectory().path);\n    const currentOwnerUri = this._directoryIndex.get(dirPath);\n    if (\n      !currentOwnerUri ||\n      normalize(currentOwnerUri.path) !== normalize(uri.path)\n    )\n      return;\n\n    // Resource already removed from _resources — scan remaining for a next-best candidate\n    const nextOwner = this.list()\n      .filter(r => normalize(r.uri.getDirectory().path) === dirPath)\n      .map(r => ({\n        resource: r,\n        priority: this._directoryIndexPriority(r.uri),\n      }))\n      .filter(({ priority }) => priority !== -1)\n      .sort((a, b) => a.priority - b.priority)[0]?.resource;\n\n    if (nextOwner) {\n      this._directoryIndex.set(dirPath, nextOwner.uri);\n    } else {\n      this._directoryIndex.delete(dirPath);\n    }\n  }\n\n  /**\n   * Returns the directory index file for the given directory path, or null if none exists.\n   * The directory path should be an absolute path string (e.g. resource.uri.getDirectory().path).\n   */\n  public findByDirectory(dirPath: string): Resource | null {\n    const ownerUri = this._directoryIndex.get(normalize(dirPath));\n    if (!ownerUri) return null;\n    return this._resources.get(this.getTrieIdentifier(ownerUri)) ?? null;\n  }\n\n  /**\n   * Returns all directory index resources whose directory path ends with the given identifier.\n   * Used to resolve wikilinks like [[bar]] or [[zoo/bar]] to directory index files.\n   * Results are sorted by path for deterministic ordering.\n   */\n  public listByDirectoryIdentifier(identifier: string): Resource[] {\n    const normalizedId = normalize(identifier);\n    const results: Resource[] = [];\n    for (const [dirPath, uri] of this._directoryIndex.entries()) {\n      if (dirPath === normalizedId || dirPath.endsWith('/' + normalizedId)) {\n        const resource = this._resources.get(this.getTrieIdentifier(uri));\n        if (resource) results.push(resource);\n      }\n    }\n    return results.sort(Resource.sortByPath);\n  }\n\n  public exists(uri: URI): boolean {\n    return isSome(this.find(uri));\n  }\n\n  public list(): Resource[] {\n    return Array.from(this._resources.values());\n  }\n\n  public resources(): IterableIterator<Resource> {\n    const resources: Array<Resource> = Array.from(\n      this._resources.values()\n    ).sort(Resource.sortByPath);\n\n    return resources.values();\n  }\n\n  public get(uri: URI): Resource {\n    const note = this.find(uri);\n    if (isSome(note)) {\n      return note;\n    } else {\n      throw new Error('Resource not found: ' + uri.path);\n    }\n  }\n\n  public listByIdentifier(identifier: string): Resource[] {\n    let needle = this.getTrieIdentifier(identifier);\n    const mdNeedle =\n      getExtension(normalize(identifier)) !== this.defaultExtension\n        ? this.getTrieIdentifier(identifier + this.defaultExtension)\n        : undefined;\n\n    let resources: Resource[] = [];\n\n    this._resources.find(needle).forEach(elm => resources.push(elm[1]));\n\n    if (mdNeedle) {\n      this._resources.find(mdNeedle).forEach(elm => resources.push(elm[1]));\n    }\n\n    // if multiple resources found, try to filter exact case matches\n    if (resources.length > 1) {\n      resources = resources.filter(\n        r =>\n          r.uri.getBasename() === identifier ||\n          r.uri.getBasename() === identifier + this.defaultExtension\n      );\n    }\n\n    return resources.sort(Resource.sortByPath);\n  }\n\n  /**\n   * Returns the minimal identifier for the given resource\n   *\n   * @param forResource the resource to compute the identifier for\n   */\n  public getIdentifier(forResource: URI, exclude?: URI[]): string {\n    const amongst = [];\n    const basename = forResource.getBasename();\n\n    this.listByIdentifier(basename).forEach(res => {\n      // skip self\n      if (res.uri.isEqual(forResource)) {\n        return;\n      }\n\n      // skip exclude list\n      if (exclude && exclude.find(ex => ex.isEqual(res.uri))) {\n        return;\n      }\n      amongst.push(res.uri);\n    });\n\n    let identifier = FoamWorkspace.getShortestIdentifier(\n      forResource.path,\n      amongst.map(uri => uri.path)\n    );\n    identifier = changeExtension(identifier, this.defaultExtension, '');\n    if (forResource.fragment) {\n      identifier += `#${forResource.fragment}`;\n    }\n    return identifier;\n  }\n\n  /**\n   * Returns the shortest unambiguous directory-style identifier for a directory index file\n   * (e.g. `bar` or `zoo/bar`), or null if the resource is not the current directory index owner.\n   *\n   * Use this when `foam.links.directory.completionStyle` is `\"directory\"`.\n   */\n  public getDirectoryIdentifier(forResource: URI): string | null {\n    const dirPath = normalize(forResource.getDirectory().path);\n    const ownerUri = this._directoryIndex.get(dirPath);\n    if (!ownerUri || normalize(ownerUri.path) !== normalize(forResource.path)) {\n      return null;\n    }\n    const otherDirPaths = Array.from(this._directoryIndex.keys()).filter(\n      dp => dp !== dirPath\n    );\n    return FoamWorkspace.getShortestIdentifier(dirPath, otherDirPaths);\n  }\n\n  /**\n   * Returns a note identifier in reversed order. Used to optimise the storage of notes in\n   * the workspace to optimise retrieval of notes.\n   *\n   * @param reference the URI path to reverse\n   */\n  private getTrieIdentifier(reference: URI | string): string {\n    let path: string;\n    if (reference instanceof URI) {\n      path = (reference as URI).path;\n    } else {\n      path = reference as string;\n    }\n\n    let reversedPath = normalize(path).split('/').reverse().join('/');\n\n    if (reversedPath.indexOf('/') < 0) {\n      reversedPath = reversedPath + '/';\n    }\n\n    return reversedPath;\n  }\n\n  public find(reference: URI | string, baseUri?: URI): Resource | null {\n    if (reference instanceof URI) {\n      return this._resources.get(this.getTrieIdentifier(reference)) ?? null;\n    }\n    let resource: Resource | null = null;\n    const [path, fragment] = (reference as string).split('#');\n    if (FoamWorkspace.isIdentifier(path)) {\n      resource = this.listByIdentifier(path)[0];\n    } else {\n      const candidates = [path, path + this.defaultExtension];\n      for (const candidate of candidates) {\n        if (isAbsolute(candidate)) {\n          // Try roots[0] first (via resolveUri which handles already-under-root paths)\n          const resolvedUri = this.resolveUri(candidate);\n          resource =\n            this._resources.get(this.getTrieIdentifier(resolvedUri)) ?? null;\n          // For workspace-relative absolute paths in multi-root workspaces,\n          // also search remaining roots in case the resource lives in a different root\n          if (!resource && this.roots.length > 1) {\n            for (let i = 1; i < this.roots.length; i++) {\n              const altUri = this.roots[i].joinPath(candidate);\n              resource =\n                this._resources.get(this.getTrieIdentifier(altUri)) ?? null;\n              if (resource) {\n                break;\n              }\n            }\n          }\n        } else {\n          const resolvedUri = isSome(baseUri)\n            ? baseUri.resolve(candidate)\n            : null;\n          resource = resolvedUri\n            ? this._resources.get(this.getTrieIdentifier(resolvedUri))\n            : null;\n        }\n        if (resource) {\n          break;\n        }\n      }\n    }\n    if (resource && fragment) {\n      resource = {\n        ...resource,\n        uri: resource.uri.with({ fragment: fragment }),\n      };\n    }\n    return resource ?? null;\n  }\n\n  public resolveLink(resource: Resource, link: ResourceLink): URI {\n    for (const provider of this.providers) {\n      if (provider.supports(resource.uri)) {\n        return provider.resolveLink(this, resource, link);\n      }\n    }\n    throw new Error(\n      `Couldn't find provider for resource \"${resource.uri.toString()}\"`\n    );\n  }\n\n  public fetch(uri: URI): Promise<Resource | null> {\n    for (const provider of this.providers) {\n      if (provider.supports(uri)) {\n        return provider.fetch(uri);\n      }\n    }\n    return Promise.resolve(null);\n  }\n\n  /**\n   * Takes a resource URI, and adds it to the workspace as a resource.\n   * If the URI is not supported by any provider or is not found, it will not\n   * add anything to the workspace, and return null.\n   *\n   * @param uri the URI where the resource is located\n   * @returns A promise to the Resource, or null if none was found\n   */\n  public async fetchAndSet(uri: URI): Promise<Resource | null> {\n    const resource = await this.fetch(uri);\n    resource && this.set(resource);\n    return resource;\n  }\n\n  public readAsMarkdown(uri: URI): Promise<string | null> {\n    for (const provider of this.providers) {\n      if (provider.supports(uri)) {\n        return provider.readAsMarkdown(uri);\n      }\n    }\n    return Promise.resolve(null);\n  }\n\n  public dispose(): void {\n    this.onDidAddEmitter.dispose();\n    this.onDidDeleteEmitter.dispose();\n    this.onDidUpdateEmitter.dispose();\n  }\n\n  static isIdentifier(path: string): boolean {\n    return !(\n      path.startsWith('/') ||\n      path.startsWith('./') ||\n      path.startsWith('../')\n    );\n  }\n\n  /**\n   * Returns the minimal identifier for the given string amongst others\n   *\n   * @param forPath the value to compute the identifier for\n   * @param amongst the set of strings within which to find the identifier\n   */\n  static getShortestIdentifier(forPath: string, amongst: string[]): string {\n    const needleTokens = forPath.split('/').reverse();\n    const haystack = amongst\n      .filter(value => value !== forPath)\n      .map(value => value.split('/').reverse());\n\n    let tokenIndex = 0;\n    let res = needleTokens;\n    while (tokenIndex < needleTokens.length) {\n      for (let j = haystack.length - 1; j >= 0; j--) {\n        if (\n          haystack[j].length < tokenIndex ||\n          needleTokens[tokenIndex] !== haystack[j][tokenIndex]\n        ) {\n          haystack.splice(j, 1);\n        }\n      }\n      if (haystack.length === 0) {\n        res = needleTokens.splice(0, tokenIndex + 1);\n        break;\n      }\n      tokenIndex++;\n    }\n    const identifier = res\n      .filter(token => token.trim() !== '')\n      .reverse()\n      .join('/');\n\n    return identifier;\n  }\n\n  static async fromProviders(\n    roots: URI[],\n    providers: ResourceProvider[],\n    dataStore: IDataStore,\n    defaultExtension: string = '.md'\n  ): Promise<FoamWorkspace> {\n    const workspace = new FoamWorkspace(roots, defaultExtension);\n    await Promise.all(providers.map(p => workspace.registerProvider(p)));\n    const files = await dataStore.list();\n    await Promise.all(files.map(f => workspace.fetchAndSet(f)));\n    return workspace;\n  }\n}\n\nconst normalize = (v: string) => v.toLocaleLowerCase();\n"
  },
  {
    "path": "packages/foam-vscode/src/core/query/dql.ts",
    "content": "import { parse as parseYaml } from 'yaml';\nimport { FoamWorkspace } from '../model/workspace';\nimport { FoamGraph } from '../model/graph';\nimport { QueryDescriptor, executeQuery, ALL_QUERY_FIELDS } from '.';\nimport { escapeHtml, renderResults } from './html';\n\nconst DQL_PLACEHOLDER = `<div class=\"foam-query-placeholder\">\n<p>Use <code>\\`\\`\\`foam-query</code> blocks to write a query to list notes. For example:</p>\n<pre>\\`\\`\\`foam-query\nfilter: \"#recipe\"\nsort: title ASC\n\\`\\`\\`</pre>\n<pre>\\`\\`\\`foam-query\nfilter: \"#recipe\"\nselect: [title, tags, backlink-count]\nformat: table\n\\`\\`\\`</pre>\n<p><strong>Filter examples:</strong></p>\n<pre>filter: \"#tag\"            # notes tagged with #tag\nfilter: \"[[note-id]]\"     # notes linked to or from note (same id as in wikilinks)\nfilter: \"*\"               # all notes\nfilter: \"/path/regex/\"    # notes whose path matches a regex\nfilter:\n  tag: recipe             # object form — same as \"#recipe\"\nfilter:\n  and:\n    - \"#recipe\"\n    - \"#published\"        # notes matching all conditions\nfilter:\n  or:\n    - \"#recipe\"\n    - \"#cooking\"          # notes matching any condition\nfilter:\n  not: \"#draft\"           # notes not matching a condition</pre>\n<pre>\\`\\`\\`foam-query\nfilter: \"#recipe\"\nselect: [title, properties.status, properties.date]\nformat: table\n\\`\\`\\`</pre>\n<p>Read the full documentation <a href=\"https://github.com/foambubble/foam/blob/main/docs/user/features/foam-queries.md\">here</a></p>\n</div>`;\n\nconst KNOWN_FIELDS = new Set<string>([\n  'filter',\n  'select',\n  'sort',\n  'limit',\n  'offset',\n  'format',\n]);\n\nconst VALID_FORMATS = new Set(['list', 'table', 'count']);\n\n/**\n * Validates a known field value. Returns an HTML warning string if invalid,\n * null if valid. Only called for non-null, non-empty-string values.\n */\nfunction validateFieldValue(key: string, value: unknown): string | null {\n  switch (key) {\n    case 'filter':\n      if (\n        typeof value !== 'string' &&\n        (typeof value !== 'object' || Array.isArray(value))\n      ) {\n        return `Field <code>filter</code> must be a string like <code>\"#tag\"</code> or a mapping — use <code>*</code> to match all notes`;\n      }\n      break;\n    case 'select': {\n      const available = ALL_QUERY_FIELDS.map(f => `<code>${f}</code>`).join(\n        ', '\n      );\n      if (!Array.isArray(value)) {\n        return `Field <code>select</code> must be a list of fields. Available: ${available}`;\n      }\n      if (value.length === 0) {\n        return `Field <code>select</code> requires at least one field. Available: ${available}`;\n      }\n      break;\n    }\n    case 'sort':\n      if (typeof value !== 'string') {\n        return `Field <code>sort</code> must be a string like <code>title ASC</code>`;\n      }\n      break;\n    case 'limit':\n      if (typeof value !== 'number' || !Number.isInteger(value) || value <= 0) {\n        return `Field <code>limit</code> must be a positive integer`;\n      }\n      break;\n    case 'offset':\n      if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {\n        return `Field <code>offset</code> must be a non-negative integer`;\n      }\n      break;\n    case 'format':\n      if (!VALID_FORMATS.has(value as string)) {\n        return `Field <code>format</code> must be one of: <code>list</code>, <code>table</code>, <code>count</code>`;\n      }\n      break;\n  }\n  return null;\n}\n\nfunction renderWarnings(warnings: string[]): string {\n  if (warnings.length === 0) return '';\n  const items = warnings.map(w => `<p>${w}</p>`).join('');\n  return `<div class=\"foam-query-warning\">${items}</div>`;\n}\n\nexport function renderDqlQuery(\n  content: string,\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  trusted: boolean,\n  toRelativePath: (path: string) => string\n): string {\n  if (content.trim() === '') {\n    return DQL_PLACEHOLDER;\n  }\n\n  const warnings: string[] = [];\n  let parsed: Record<string, unknown> = {};\n\n  try {\n    parsed = (parseYaml(content) as Record<string, unknown>) ?? {};\n  } catch (e) {\n    // Try progressively shorter content (drop lines from the end) until we get\n    // a valid parse, then warn about the first dropped line.\n    const lines = content.split('\\n');\n    let recovered = false;\n    for (let i = lines.length - 1; i >= 1; i--) {\n      const truncated = lines.slice(0, i).join('\\n');\n      if (truncated.trim() === '') break;\n      try {\n        parsed = (parseYaml(truncated) as Record<string, unknown>) ?? {};\n        warnings.push(`Line ${i + 1} has a syntax error and was ignored`);\n        recovered = true;\n        break;\n      } catch {\n        // keep trying shorter\n      }\n    }\n    if (!recovered) {\n      return (\n        DQL_PLACEHOLDER +\n        `<div class=\"foam-query-error\">YAML parse error: ${escapeHtml(\n          (e as Error).message\n        )}</div>`\n      );\n    }\n  }\n\n  if (typeof parsed !== 'object' || Array.isArray(parsed)) {\n    return DQL_PLACEHOLDER;\n  }\n\n  let filterIsEmpty = false;\n  for (const key of Object.keys(parsed)) {\n    if (!KNOWN_FIELDS.has(key)) {\n      warnings.push(`Unknown field <code>${escapeHtml(key)}</code> — ignored`);\n      delete parsed[key];\n    } else if (parsed[key] === null || parsed[key] === '') {\n      if (key === 'filter') {\n        filterIsEmpty = true;\n        warnings.push(\n          `Field <code>filter</code> requires a value — use <code>*</code> to match all notes`\n        );\n      } else {\n        warnings.push(\n          `Missing value for field <code>${escapeHtml(key)}</code> — ignored`\n        );\n      }\n      delete parsed[key];\n    } else {\n      const warning = validateFieldValue(key, parsed[key]);\n      if (warning) {\n        if (key === 'filter') filterIsEmpty = true;\n        warnings.push(warning);\n        delete parsed[key];\n      }\n    }\n  }\n\n  if (Object.keys(parsed).length === 0 || filterIsEmpty) {\n    return DQL_PLACEHOLDER + renderWarnings(warnings);\n  }\n\n  // Validate individual elements of the select array.\n  // Allow known fields and properties.X dot-notation; strip and warn otherwise.\n  if (Array.isArray(parsed.select)) {\n    const validFields = ALL_QUERY_FIELDS.join(', ');\n    const valid: string[] = [];\n    for (const field of parsed.select as string[]) {\n      if (ALL_QUERY_FIELDS.includes(field) || /^properties\\..+$/.test(field)) {\n        valid.push(field);\n      } else {\n        warnings.push(\n          `Unknown select field <code>${escapeHtml(\n            field\n          )}</code> — available: ${validFields}, or <code>properties.fieldname</code>`\n        );\n      }\n    }\n    if (valid.length === 0) {\n      delete parsed.select; // fall back to default\n    } else {\n      parsed.select = valid;\n    }\n  }\n\n  const descriptor = parsed as QueryDescriptor;\n\n  // Ensure path is always fetched when title is selected so link generation\n  // works in both list and table formats, even if the user didn't select path.\n  const needsPath =\n    descriptor.select &&\n    descriptor.select.includes('title') &&\n    !descriptor.select.includes('path');\n  const execDescriptor: QueryDescriptor = needsPath\n    ? { ...descriptor, select: [...descriptor.select, 'path'] }\n    : descriptor;\n\n  const results = executeQuery(execDescriptor, workspace, graph, { trusted });\n  return (\n    renderWarnings(warnings) +\n    renderResults(results, descriptor, toRelativePath)\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/query/html.ts",
    "content": "import { QueryDescriptor, ResourceView } from '.';\n\nexport function escapeHtml(text: string): string {\n  return String(text)\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;');\n}\n\nexport function noteLink(\n  title: string,\n  uriPath: string,\n  toRelativePath: (path: string) => string\n): string {\n  try {\n    const rel = toRelativePath(uriPath);\n    const href = encodeURI(`/${rel}`);\n    return `<a class=\"foam-note-link\" title=\"${escapeHtml(\n      title\n    )}\" href=\"${href}\" data-href=\"${href}\">${escapeHtml(title)}</a>`;\n  } catch {\n    return escapeHtml(title);\n  }\n}\n\nfunction cellValue(\n  field: string,\n  value: unknown,\n  row: ResourceView,\n  toRelativePath: (path: string) => string\n): string {\n  if (field === 'title' && typeof row.path === 'string' && row.path) {\n    return noteLink(String(value ?? ''), row.path, toRelativePath);\n  }\n  if (Array.isArray(value)) return escapeHtml(value.join(', '));\n  if (value === undefined || value === null) return '';\n  return escapeHtml(String(value));\n}\n\nexport function renderList(\n  results: ResourceView[],\n  fields: string[],\n  toRelativePath: (path: string) => string\n): string {\n  if (results.length === 0) {\n    return '<p class=\"foam-query-empty\">No results</p>';\n  }\n  const items = results\n    .map(r => {\n      const path = typeof r.path === 'string' ? r.path : '';\n      const parts = fields\n        .map(field => {\n          const value = r[field];\n          if (field === 'title') {\n            const text =\n              value != null ? String(value) : path.split('/').pop() ?? path;\n            return path\n              ? noteLink(text, path, toRelativePath)\n              : escapeHtml(text);\n          }\n          if (Array.isArray(value)) return escapeHtml(value.join(', '));\n          return value != null ? escapeHtml(String(value)) : '';\n        })\n        .filter(Boolean);\n      return parts.length > 0 ? `<li>${parts.join(' · ')}</li>` : null;\n    })\n    .filter((item): item is string => item !== null)\n    .join('\\n');\n  if (items.length === 0) {\n    return '<p class=\"foam-query-empty\">No results</p>';\n  }\n  return `<ul class=\"foam-query-results\">\\n${items}\\n</ul>`;\n}\n\nexport function renderTable(\n  results: ResourceView[],\n  fields: string[],\n  toRelativePath: (path: string) => string\n): string {\n  if (results.length === 0) {\n    return '<p class=\"foam-query-empty\">No results</p>';\n  }\n  const headers = fields.map(f => `<th>${escapeHtml(f)}</th>`).join('');\n  const rows = results\n    .map(r => {\n      const cells = fields\n        .map(f => `<td>${cellValue(f, r[f], r, toRelativePath)}</td>`)\n        .join('');\n      return `<tr>${cells}</tr>`;\n    })\n    .join('\\n');\n  return (\n    `<table class=\"foam-query-results\">\\n` +\n    `<thead><tr>${headers}</tr></thead>\\n` +\n    `<tbody>\\n${rows}\\n</tbody>\\n` +\n    `</table>`\n  );\n}\n\nexport function renderCount(results: ResourceView[]): string {\n  const n = results.length;\n  return `<span class=\"foam-query-results\">${n} note${\n    n === 1 ? '' : 's'\n  }</span>`;\n}\n\n/**\n * Renders query results as HTML based on the descriptor's format.\n */\nexport function renderResults(\n  results: ResourceView[],\n  descriptor: QueryDescriptor,\n  toRelativePath: (path: string) => string\n): string {\n  const format =\n    descriptor.format ??\n    (descriptor.select && descriptor.select.length > 1 ? 'table' : 'list');\n  switch (format) {\n    case 'table':\n      return renderTable(\n        results,\n        descriptor.select ?? ['title', 'path'],\n        toRelativePath\n      );\n    case 'count':\n      return renderCount(results);\n    default:\n      return renderList(\n        results,\n        descriptor.select ?? ['title'],\n        toRelativePath\n      );\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/query/index.test.ts",
    "content": "import { Logger } from '../utils/log';\nimport { FoamGraph } from '../model/graph';\nimport { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { executeQuery, parseFilter, QueryDescriptor } from '.';\n\nLogger.setLevel('error');\n\n// Helper: build a workspace + graph from a list of notes.\n// Notes are added in order — links are resolved against whatever is in the workspace.\nfunction makeWorkspaceAndGraph(notes: ReturnType<typeof createTestNote>[]) {\n  const workspace = createTestWorkspace();\n  notes.forEach(n => workspace.set(n));\n  const graph = FoamGraph.fromWorkspace(workspace, false);\n  return { workspace, graph };\n}\n\n// ─── parseFilter ─────────────────────────────────────────────────────────────\n\ndescribe('parseFilter — shorthand strings', () => {\n  it('undefined filter matches all resources', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph([\n      createTestNote({ uri: '/a.md' }),\n    ]);\n    const pred = parseFilter(undefined, workspace, graph, false);\n    expect(pred(workspace.list()[0])).toBe(true);\n  });\n\n  it('\"*\" matches all resources', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph([\n      createTestNote({ uri: '/a.md' }),\n    ]);\n    const pred = parseFilter('*', workspace, graph, false);\n    expect(pred(workspace.list()[0])).toBe(true);\n  });\n\n  it('\"#tag\" matches notes with that tag, not others', () => {\n    const noteA = createTestNote({ uri: '/a.md', tags: ['research'] });\n    const noteB = createTestNote({ uri: '/b.md', tags: ['other'] });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter('#research', workspace, graph, false);\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n\n  it('\"[[note]]\" matches notes that link to it or that it links to', () => {\n    const noteA = createTestNote({ uri: '/a.md', links: [{ slug: 'b' }] }); // noteA → noteB\n    const noteB = createTestNote({ uri: '/b.md', links: [{ slug: 'c' }] }); // noteB → noteC\n    const noteC = createTestNote({ uri: '/c.md' });\n    const noteD = createTestNote({ uri: '/d.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([\n      noteA,\n      noteB,\n      noteC,\n      noteD,\n    ]);\n\n    const pred = parseFilter('[[b]]', workspace, graph, false);\n    expect(pred(noteA)).toBe(true); // noteA links TO noteB\n    expect(pred(noteB)).toBe(false); // noteB is the reference node, not a neighbor\n    expect(pred(noteC)).toBe(true); // noteC is linked FROM noteB (outlink of noteB)\n    expect(pred(noteD)).toBe(false); // noteD has no connection to noteB\n  });\n\n  it('\"[[note]]\" returns false for all when the identifier is not found', () => {\n    const noteA = createTestNote({ uri: '/a.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const pred = parseFilter('[[nonexistent]]', workspace, graph, false);\n    expect(pred(noteA)).toBe(false);\n  });\n\n  it('\"/regex/\" matches notes whose path matches the regex', () => {\n    const noteA = createTestNote({ uri: '/projects/work.md' });\n    const noteB = createTestNote({ uri: '/journal/today.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter('/projects/', workspace, graph, false);\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n});\n\ndescribe('parseFilter — structured keys', () => {\n  it('tag filter matches notes with that tag (with or without #)', () => {\n    const noteA = createTestNote({ uri: '/a.md', tags: ['research'] });\n    const noteB = createTestNote({ uri: '/b.md', tags: ['other'] });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const predWithHash = parseFilter(\n      { tag: '#research' },\n      workspace,\n      graph,\n      false\n    );\n    const predWithout = parseFilter(\n      { tag: 'research' },\n      workspace,\n      graph,\n      false\n    );\n\n    expect(predWithHash(noteA)).toBe(true);\n    expect(predWithHash(noteB)).toBe(false);\n    expect(predWithout(noteA)).toBe(true);\n    expect(predWithout(noteB)).toBe(false);\n  });\n\n  it('type filter matches notes of that type only', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'daily-note' });\n    const noteB = createTestNote({ uri: '/b.md', type: 'note' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter({ type: 'daily-note' }, workspace, graph, false);\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n\n  it('path filter matches notes whose path satisfies the regex', () => {\n    const noteA = createTestNote({ uri: '/archive/old.md' });\n    const noteB = createTestNote({ uri: '/notes/current.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter({ path: '^/archive' }, workspace, graph, false);\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n\n  it('title filter matches notes whose title satisfies the regex', () => {\n    const noteA = createTestNote({ uri: '/a.md', title: 'Meeting notes' });\n    const noteB = createTestNote({ uri: '/b.md', title: 'Project plan' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter({ title: '^Meeting' }, workspace, graph, false);\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n\n  it('links_to filter matches notes that have an outbound link to the identifier', () => {\n    const noteA = createTestNote({ uri: '/a.md', links: [{ slug: 'b' }] });\n    const noteB = createTestNote({ uri: '/b.md' });\n    const noteC = createTestNote({ uri: '/c.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter({ links_to: 'b' }, workspace, graph, false);\n    expect(pred(noteA)).toBe(true); // noteA links to noteB\n    expect(pred(noteB)).toBe(false); // noteB doesn't link to noteB\n    expect(pred(noteC)).toBe(false); // noteC doesn't link to noteB\n  });\n\n  it('links_from filter matches notes that are linked from the identifier', () => {\n    const noteA = createTestNote({ uri: '/a.md', links: [{ slug: 'b' }] });\n    const noteB = createTestNote({ uri: '/b.md' });\n    const noteC = createTestNote({ uri: '/c.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter({ links_from: 'a' }, workspace, graph, false);\n    expect(pred(noteA)).toBe(false); // noteA is the source, not the target\n    expect(pred(noteB)).toBe(true); // noteB is linked from noteA\n    expect(pred(noteC)).toBe(false); // noteC is not linked from noteA\n  });\n\n  it('links_to returns false for all when identifier is not found', () => {\n    const noteA = createTestNote({ uri: '/a.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const pred = parseFilter(\n      { links_to: 'nonexistent' },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(false);\n  });\n\n  it('links_from returns false for all when identifier is not found', () => {\n    const noteA = createTestNote({ uri: '/a.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const pred = parseFilter(\n      { links_from: 'nonexistent' },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(false);\n  });\n});\n\ndescribe('parseFilter — logical operators', () => {\n  it('and requires all sub-filters to match', () => {\n    const noteA = createTestNote({\n      uri: '/a.md',\n      tags: ['research'],\n      type: 'note',\n    });\n    const noteB = createTestNote({\n      uri: '/b.md',\n      tags: ['research'],\n      type: 'daily-note',\n    });\n    const noteC = createTestNote({\n      uri: '/c.md',\n      tags: ['other'],\n      type: 'note',\n    });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter(\n      { and: [{ tag: 'research' }, { type: 'note' }] },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n    expect(pred(noteC)).toBe(false);\n  });\n\n  it('or requires at least one sub-filter to match', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'daily-note' });\n    const noteB = createTestNote({ uri: '/b.md', type: 'weekly-note' });\n    const noteC = createTestNote({ uri: '/c.md', type: 'note' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter(\n      { or: [{ type: 'daily-note' }, { type: 'weekly-note' }] },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(true);\n    expect(pred(noteC)).toBe(false);\n  });\n\n  it('not inverts the sub-filter', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'daily-note' });\n    const noteB = createTestNote({ uri: '/b.md', type: 'note' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter(\n      { not: { type: 'daily-note' } },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(false);\n    expect(pred(noteB)).toBe(true);\n  });\n\n  it('nested operators: and with not', () => {\n    const noteA = createTestNote({ uri: '/a.md', tags: ['research', 'draft'] });\n    const noteB = createTestNote({ uri: '/b.md', tags: ['research'] });\n    const noteC = createTestNote({ uri: '/c.md', tags: ['draft'] });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter(\n      { and: [{ tag: 'research' }, { not: { tag: 'draft' } }] },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(false); // has research but also draft\n    expect(pred(noteB)).toBe(true); // has research, no draft\n    expect(pred(noteC)).toBe(false); // no research\n  });\n});\n\ndescribe('parseFilter — expression', () => {\n  it('expression is skipped (all pass) when workspace is not trusted', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'type-1' });\n    const noteB = createTestNote({ uri: '/b.md', type: 'type-2' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter(\n      { expression: 'resource.type === \"type-1\"' },\n      workspace,\n      graph,\n      false\n    );\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(true);\n  });\n\n  it('expression is evaluated when workspace is trusted', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'type-1' });\n    const noteB = createTestNote({ uri: '/b.md', type: 'type-2' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const pred = parseFilter(\n      { expression: 'resource.type === \"type-1\"' },\n      workspace,\n      graph,\n      true\n    );\n    expect(pred(noteA)).toBe(true);\n    expect(pred(noteB)).toBe(false);\n  });\n\n  it('expression can access graph-derived backlinks', () => {\n    const noteA = createTestNote({ uri: '/a.md', links: [{ slug: 'b' }] });\n    const noteB = createTestNote({ uri: '/b.md' });\n    const noteC = createTestNote({ uri: '/c.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const pred = parseFilter(\n      { expression: 'resource.backlinks.length > 0' },\n      workspace,\n      graph,\n      true\n    );\n    expect(pred(noteA)).toBe(false); // noteA has no backlinks\n    expect(pred(noteB)).toBe(true); // noteB is linked from noteA\n    expect(pred(noteC)).toBe(false);\n  });\n\n  it('expression runtime error excludes the resource and does not throw', () => {\n    const noteA = createTestNote({ uri: '/a.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const pred = parseFilter(\n      { expression: 'throw new Error(\"boom\")' },\n      workspace,\n      graph,\n      true\n    );\n    expect(pred(noteA)).toBe(false);\n  });\n});\n\n// ─── executeQuery ─────────────────────────────────────────────────────────────\n\ndescribe('executeQuery — projection', () => {\n  it('returns only the selected fields', () => {\n    const noteA = createTestNote({\n      uri: '/a.md',\n      title: 'Alpha',\n      tags: ['t1'],\n    });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const results = executeQuery(\n      { select: ['title', 'tags'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results).toHaveLength(1);\n    expect(results[0]).toEqual({ title: 'Alpha', tags: ['t1'] });\n    expect(results[0]).not.toHaveProperty('path');\n  });\n\n  it('defaults to [title, path] when select is omitted', () => {\n    const noteA = createTestNote({ uri: '/a.md', title: 'Alpha' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const [result] = executeQuery({}, workspace, graph, { trusted: false });\n    expect(result).toHaveProperty('title');\n    expect(result).toHaveProperty('path');\n    expect(Object.keys(result)).toHaveLength(2);\n  });\n\n  it('computed field backlink-count reflects graph state', () => {\n    const noteA = createTestNote({ uri: '/a.md', links: [{ slug: 'b' }] });\n    const noteB = createTestNote({ uri: '/b.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB]);\n\n    const results = executeQuery(\n      { filter: { path: '/b.md' }, select: ['title', 'backlink-count'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results[0]['backlink-count']).toBe(1);\n  });\n\n  it('computed field outlink-count reflects graph state', () => {\n    const noteA = createTestNote({\n      uri: '/a.md',\n      links: [{ slug: 'b' }, { slug: 'c' }],\n    });\n    const noteB = createTestNote({ uri: '/b.md' });\n    const noteC = createTestNote({ uri: '/c.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA, noteB, noteC]);\n\n    const results = executeQuery(\n      { filter: { path: '/a.md' }, select: ['title', 'outlink-count'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results[0]['outlink-count']).toBe(2);\n  });\n\n  it('unknown field in select is included as undefined', () => {\n    const noteA = createTestNote({ uri: '/a.md' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const [result] = executeQuery(\n      { select: ['title', 'nonexistent'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(result).toHaveProperty('nonexistent', undefined);\n  });\n});\n\ndescribe('executeQuery — sorting', () => {\n  it('sorts by string field ascending by default', () => {\n    const notes = [\n      createTestNote({ uri: '/c.md', title: 'Gamma' }),\n      createTestNote({ uri: '/a.md', title: 'Alpha' }),\n      createTestNote({ uri: '/b.md', title: 'Beta' }),\n    ];\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n\n    const results = executeQuery(\n      { sort: 'title', select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results.map(r => r.title)).toEqual(['Alpha', 'Beta', 'Gamma']);\n  });\n\n  it('sorts by string field descending', () => {\n    const notes = [\n      createTestNote({ uri: '/a.md', title: 'Alpha' }),\n      createTestNote({ uri: '/b.md', title: 'Beta' }),\n      createTestNote({ uri: '/c.md', title: 'Gamma' }),\n    ];\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n\n    const results = executeQuery(\n      { sort: 'title DESC', select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results.map(r => r.title)).toEqual(['Gamma', 'Beta', 'Alpha']);\n  });\n\n  it('unknown sort field preserves stable order', () => {\n    const notes = [\n      createTestNote({ uri: '/a.md', title: 'Alpha' }),\n      createTestNote({ uri: '/b.md', title: 'Beta' }),\n    ];\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n\n    const baseline = executeQuery({ select: ['title'] }, workspace, graph, {\n      trusted: false,\n    });\n    const sorted = executeQuery(\n      { sort: 'nonexistent', select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(sorted.map(r => r.title)).toEqual(baseline.map(r => r.title));\n  });\n});\n\ndescribe('executeQuery — pagination', () => {\n  const notes = [\n    createTestNote({ uri: '/a.md', title: 'A' }),\n    createTestNote({ uri: '/b.md', title: 'B' }),\n    createTestNote({ uri: '/c.md', title: 'C' }),\n    createTestNote({ uri: '/d.md', title: 'D' }),\n  ];\n\n  it('limit returns at most N results', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n    const results = executeQuery(\n      { sort: 'title', limit: 2, select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results).toHaveLength(2);\n    expect(results.map(r => r.title)).toEqual(['A', 'B']);\n  });\n\n  it('offset skips the first N results', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n    const results = executeQuery(\n      { sort: 'title', offset: 2, select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results.map(r => r.title)).toEqual(['C', 'D']);\n  });\n\n  it('limit and offset together', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n    const results = executeQuery(\n      { sort: 'title', offset: 1, limit: 2, select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results.map(r => r.title)).toEqual(['B', 'C']);\n  });\n\n  it('limit larger than result count returns all results', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n    const results = executeQuery(\n      { limit: 100, select: ['title'] },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results).toHaveLength(4);\n  });\n});\n\ndescribe('executeQuery — end to end', () => {\n  it('returns empty array for an empty workspace', () => {\n    const { workspace, graph } = makeWorkspaceAndGraph([]);\n    const results = executeQuery({}, workspace, graph, { trusted: false });\n    expect(results).toEqual([]);\n  });\n\n  it('returns empty array when no notes match the filter', () => {\n    const noteA = createTestNote({ uri: '/a.md', type: 'note' });\n    const { workspace, graph } = makeWorkspaceAndGraph([noteA]);\n\n    const results = executeQuery(\n      { filter: { type: 'daily-note' } },\n      workspace,\n      graph,\n      { trusted: false }\n    );\n    expect(results).toEqual([]);\n  });\n\n  it('applies filter + select + sort + limit together', () => {\n    const notes = [\n      createTestNote({ uri: '/a.md', title: 'Alpha', tags: ['research'] }),\n      createTestNote({ uri: '/b.md', title: 'Beta', tags: ['research'] }),\n      createTestNote({ uri: '/c.md', title: 'Gamma', tags: ['research'] }),\n      createTestNote({ uri: '/d.md', title: 'Delta', tags: ['other'] }),\n    ];\n    const { workspace, graph } = makeWorkspaceAndGraph(notes);\n\n    const query: QueryDescriptor = {\n      filter: { tag: 'research' },\n      select: ['title'],\n      sort: 'title DESC',\n      limit: 2,\n    };\n    const results = executeQuery(query, workspace, graph, { trusted: false });\n    expect(results).toHaveLength(2);\n    expect(results.map(r => r.title)).toEqual(['Gamma', 'Beta']);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/query/index.ts",
    "content": "import { Resource } from '../model/note';\nimport { FoamWorkspace } from '../model/workspace';\nimport { FoamGraph } from '../model/graph';\nimport { Logger } from '../utils/log';\n\nexport type QueryFilter =\n  | string // shorthand: \"#tag\", \"[[note-id]]\", \"*\", \"/regex/\"\n  | {\n      tag?: string;\n      type?: string;\n      path?: string; // regex\n      title?: string; // regex\n      links_to?: string; // note identifier — this resource has an outbound link to it\n      links_from?: string; // note identifier — this resource is linked from it\n      expression?: string; // JS expression; requires trusted workspace\n      and?: QueryFilter[];\n      or?: QueryFilter[];\n      not?: QueryFilter;\n    };\n\nexport interface QueryDescriptor {\n  filter?: QueryFilter;\n  select?: string[];\n  sort?: string; // \"field [ASC|DESC]\"\n  limit?: number;\n  offset?: number;\n  format?: 'table' | 'list' | 'count';\n}\n\nexport type ResourceView = Record<string, unknown>;\n\nconst DEFAULT_SELECT = ['title', 'path'];\n\n// --- Filter ---\n\ntype Predicate = (r: Resource) => boolean;\n\n/**\n * Builds a resource context for use in JS expressions.\n * Augments the raw Resource with graph-derived fields so users can write\n * `resource.backlinks.length > 3` in expressions.\n */\nfunction makeExpressionContext(r: Resource, graph: FoamGraph) {\n  return {\n    ...r,\n    path: r.uri.path,\n    tags: r.tags.map(t => t.label),\n    backlinks: graph.getBacklinks(r.uri),\n    outlinks: graph.getLinks(r.uri),\n  };\n}\n\nexport function parseFilter(\n  filter: QueryFilter | undefined,\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  trusted: boolean\n): Predicate {\n  if (filter === undefined) return () => true;\n\n  if (typeof filter === 'string') {\n    return parseShorthand(filter, workspace, graph);\n  }\n\n  const predicates: Predicate[] = [];\n\n  if (filter.tag !== undefined) {\n    const label = filter.tag.startsWith('#') ? filter.tag.slice(1) : filter.tag;\n    predicates.push(r => r.tags.some(t => t.label === label));\n  }\n\n  if (filter.type !== undefined) {\n    const type = filter.type;\n    predicates.push(r => r.type === type);\n  }\n\n  if (filter.path !== undefined) {\n    const re = new RegExp(filter.path);\n    predicates.push(r => re.test(r.uri.path));\n  }\n\n  if (filter.title !== undefined) {\n    const re = new RegExp(filter.title);\n    predicates.push(r => re.test(r.title));\n  }\n\n  if (filter.links_to !== undefined) {\n    const target = workspace.find(filter.links_to);\n    if (!target) {\n      Logger.warn(\n        `[Query] links_to: \"${filter.links_to}\" not found in workspace`\n      );\n      predicates.push(() => false);\n    } else {\n      const targetPath = target.uri.path;\n      predicates.push(r =>\n        graph.getLinks(r.uri).some(c => c.target.path === targetPath)\n      );\n    }\n  }\n\n  if (filter.links_from !== undefined) {\n    const source = workspace.find(filter.links_from);\n    if (!source) {\n      Logger.warn(\n        `[Query] links_from: \"${filter.links_from}\" not found in workspace`\n      );\n      predicates.push(() => false);\n    } else {\n      const sourcePath = source.uri.path;\n      predicates.push(r =>\n        graph.getBacklinks(r.uri).some(c => c.source.path === sourcePath)\n      );\n    }\n  }\n\n  if (filter.expression !== undefined) {\n    if (trusted) {\n      const expr = filter.expression;\n      predicates.push(r => {\n        try {\n          // eslint-disable-next-line @typescript-eslint/no-unused-vars\n          const resource = makeExpressionContext(r, graph);\n\n          return Boolean(eval(expr));\n        } catch (e) {\n          Logger.warn(`[Query] expression error: ${e}`);\n          return false;\n        }\n      });\n    } else {\n      Logger.warn(\n        '[Query] expression filter skipped — workspace is not trusted'\n      );\n    }\n  }\n\n  if (filter.and !== undefined) {\n    const subs = filter.and.map(f => parseFilter(f, workspace, graph, trusted));\n    predicates.push(r => subs.every(p => p(r)));\n  }\n\n  if (filter.or !== undefined) {\n    const subs = filter.or.map(f => parseFilter(f, workspace, graph, trusted));\n    predicates.push(r => subs.some(p => p(r)));\n  }\n\n  if (filter.not !== undefined) {\n    const sub = parseFilter(filter.not, workspace, graph, trusted);\n    predicates.push(r => !sub(r));\n  }\n\n  if (predicates.length === 0) return () => true;\n  return r => predicates.every(p => p(r));\n}\n\nfunction parseShorthand(\n  filter: string,\n  workspace: FoamWorkspace,\n  graph: FoamGraph\n): Predicate {\n  if (filter === '*' || filter === '') return () => true;\n\n  // \"#tag\"\n  if (filter.startsWith('#')) {\n    const label = filter.slice(1);\n    return r => r.tags.some(t => t.label === label);\n  }\n\n  // \"[[note-id]]\" — same identifier as used in wikilinks\n  if (filter.startsWith('[[') && filter.endsWith(']]')) {\n    const identifier = filter.slice(2, -2);\n    const target = workspace.find(identifier);\n    if (!target) {\n      Logger.warn(`[Query] [[${identifier}]] not found in workspace`);\n      return () => false;\n    }\n    const targetPath = target.uri.path;\n    return r =>\n      graph.getLinks(r.uri).some(c => c.target.path === targetPath) ||\n      graph.getBacklinks(r.uri).some(c => c.source.path === targetPath);\n  }\n\n  // \"/regex/\"\n  if (filter.startsWith('/') && filter.endsWith('/') && filter.length > 2) {\n    const re = new RegExp(filter.slice(1, -1));\n    return r => re.test(r.uri.path);\n  }\n\n  Logger.warn(`[Query] unrecognized shorthand filter: \"${filter}\"`);\n  return () => true;\n}\n\n// --- Projection ---\n\nfunction buildFullView(r: Resource, graph: FoamGraph): Record<string, unknown> {\n  return {\n    title: r.title,\n    path: r.uri.path,\n    type: r.type,\n    tags: r.tags.map(t => t.label),\n    aliases: r.aliases.map(a => a.title),\n    links: r.links,\n    sections: r.sections.map(s => s.label),\n    blocks: r.blocks,\n    properties: r.properties,\n    'backlink-count': graph.getBacklinks(r.uri).length,\n    'outlink-count': graph.getLinks(r.uri).length,\n  };\n}\n\nfunction resolveField(full: Record<string, unknown>, field: string): unknown {\n  const dot = field.indexOf('.');\n  if (dot === -1) return full[field];\n  const parent = full[field.slice(0, dot)];\n  if (parent == null || typeof parent !== 'object') return undefined;\n  return (parent as Record<string, unknown>)[field.slice(dot + 1)];\n}\n\nfunction projectResource(\n  r: Resource,\n  graph: FoamGraph,\n  fields: string[]\n): ResourceView {\n  const full = buildFullView(r, graph);\n  return Object.fromEntries(fields.map(f => [f, resolveField(full, f)]));\n}\n\n// --- Sorting ---\n\nfunction compareValues(a: unknown, b: unknown): number {\n  if (a === undefined && b === undefined) return 0;\n  if (a === undefined) return 1;\n  if (b === undefined) return -1;\n  if (a instanceof Date && b instanceof Date) return a.getTime() - b.getTime();\n  if (typeof a === 'number' && typeof b === 'number') return a - b;\n  return String(a).localeCompare(String(b));\n}\n\nfunction parseSortDescriptor(sort: string): {\n  field: string;\n  direction: 'asc' | 'desc';\n} {\n  const parts = sort.trim().split(/\\s+/);\n  return {\n    field: parts[0],\n    direction: parts[1]?.toUpperCase() === 'DESC' ? 'desc' : 'asc',\n  };\n}\n\n// All fields produced by buildFullView — used by QueryResult to fetch everything\n// before JS predicates are applied.\nexport const ALL_QUERY_FIELDS = [\n  'title',\n  'path',\n  'type',\n  'tags',\n  'aliases',\n  'sections',\n  'blocks',\n  'properties',\n  'backlink-count',\n  'outlink-count',\n];\n\n// --- QueryResult (fluent JS query builder) ------------------------------\n\n/**\n * Fluent builder for programmatic queries.\n * Call `foam.pages(filter)` in foam-query-js blocks to obtain one.\n */\nexport class QueryResult {\n  private _descriptor: QueryDescriptor;\n  private _jsPredicates: Array<(r: ResourceView) => boolean> = [];\n\n  constructor(\n    private readonly workspace: FoamWorkspace,\n    private readonly graph: FoamGraph,\n    private readonly trusted: boolean,\n    filter?: QueryFilter\n  ) {\n    this._descriptor = { filter };\n  }\n\n  get descriptor(): QueryDescriptor {\n    return this._descriptor;\n  }\n\n  private clone(): QueryResult {\n    const c = new QueryResult(\n      this.workspace,\n      this.graph,\n      this.trusted,\n      this._descriptor.filter\n    );\n    c._descriptor = { ...this._descriptor };\n    c._jsPredicates = [...this._jsPredicates];\n    return c;\n  }\n\n  /** Add a JS predicate applied after the base filter. */\n  where(predicate: (r: ResourceView) => boolean): QueryResult {\n    const c = this.clone();\n    c._jsPredicates = [...c._jsPredicates, predicate];\n    return c;\n  }\n\n  sortBy(field: string, direction: 'asc' | 'desc' = 'asc'): QueryResult {\n    const c = this.clone();\n    c._descriptor = {\n      ...c._descriptor,\n      sort: `${field} ${direction.toUpperCase()}`,\n    };\n    return c;\n  }\n\n  limit(n: number): QueryResult {\n    const c = this.clone();\n    c._descriptor = { ...c._descriptor, limit: n };\n    return c;\n  }\n\n  offset(n: number): QueryResult {\n    const c = this.clone();\n    c._descriptor = { ...c._descriptor, offset: n };\n    return c;\n  }\n\n  select(fields: string[]): QueryResult {\n    const c = this.clone();\n    c._descriptor = { ...c._descriptor, select: fields };\n    return c;\n  }\n\n  format(f: 'table' | 'list' | 'count'): QueryResult {\n    const c = this.clone();\n    c._descriptor = { ...c._descriptor, format: f };\n    return c;\n  }\n\n  /**\n   * Executes the query and returns projected ResourceViews.\n   * JS predicates (.where()) are applied before projection/sort/paginate\n   * so they have access to all fields.\n   */\n  toArray(): ResourceView[] {\n    const baseDescriptor: QueryDescriptor = {\n      filter: this._descriptor.filter,\n      select: ALL_QUERY_FIELDS,\n    };\n    let results = executeQuery(baseDescriptor, this.workspace, this.graph, {\n      trusted: this.trusted,\n    });\n\n    for (const pred of this._jsPredicates) {\n      try {\n        results = results.filter(pred);\n      } catch (e) {\n        Logger.warn(`[foam-query] JS predicate error: ${e}`);\n      }\n    }\n\n    if (this._descriptor.sort) {\n      const { field, direction } = parseSortDescriptor(this._descriptor.sort);\n      results = [...results].sort((a, b) => {\n        const cmp = compareValues(a[field], b[field]);\n        return direction === 'desc' ? -cmp : cmp;\n      });\n    }\n\n    const offsetN = this._descriptor.offset ?? 0;\n    if (offsetN > 0) results = results.slice(offsetN);\n    if (this._descriptor.limit !== undefined)\n      results = results.slice(0, this._descriptor.limit);\n\n    const fields = this._descriptor.select ?? DEFAULT_SELECT;\n    return results.map(r => Object.fromEntries(fields.map(f => [f, r[f]])));\n  }\n}\n\n// --- executeQuery ---\n\nexport function executeQuery(\n  query: QueryDescriptor,\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  options: { trusted: boolean }\n): ResourceView[] {\n  const predicate = parseFilter(\n    query.filter,\n    workspace,\n    graph,\n    options.trusted\n  );\n  const fields = query.select ?? DEFAULT_SELECT;\n\n  let results = workspace\n    .list()\n    .filter(predicate)\n    .map(r => projectResource(r, graph, fields));\n\n  if (query.sort) {\n    const { field, direction } = parseSortDescriptor(query.sort);\n    results = [...results].sort((a, b) => {\n      const cmp = compareValues(a[field], b[field]);\n      return direction === 'desc' ? -cmp : cmp;\n    });\n  }\n\n  const offset = query.offset ?? 0;\n  if (offset > 0) {\n    results = results.slice(offset);\n  }\n\n  if (query.limit !== undefined) {\n    results = results.slice(0, query.limit);\n  }\n\n  return results;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/query/js.ts",
    "content": "import * as vm from 'vm';\nimport { FoamWorkspace } from '../model/workspace';\nimport { FoamGraph } from '../model/graph';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport { QueryFilter, QueryResult } from '.';\nimport { toSlug } from '../utils/slug';\nimport dayjs from 'dayjs';\nimport { escapeHtml, renderList, renderResults } from './html';\n\nconst EXECUTION_TIMEOUT = 10_000;\n\n// Globals that must not be accessible in the query sandbox.\nconst BLOCKED_GLOBALS = [\n  'require',\n  'module',\n  'exports',\n  '__dirname',\n  '__filename',\n  'global',\n  'process',\n  'Buffer',\n  'setImmediate',\n  'clearImmediate',\n  'setInterval',\n  'clearInterval',\n  'setTimeout',\n  'clearTimeout',\n  'eval',\n  'Function',\n];\n\nconst JS_PLACEHOLDER = `<div class=\"foam-query-placeholder\">\n<p>Use <code>\\`\\`\\`foam-query-js</code> blocks to write a script to query notes. For example:</p>\n<pre>\\`\\`\\`foam-query-js\nrender(foam.pages('#my-tag').sortBy('title').format('list'));\n\\`\\`\\`</pre>\n<p>Read the full documentation <a href=\"https://github.com/foambubble/foam/blob/main/docs/user/features/foam-queries.md\">here</a></p>\n</div>`;\n\nexport function renderJsQuery(\n  code: string,\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  trusted: boolean,\n  toRelativePath: (path: string) => string\n): string {\n  if (code.trim() === '') {\n    return JS_PLACEHOLDER;\n  }\n\n  if (!trusted) {\n    return `<div class=\"foam-query-untrusted\">foam-query-js requires a trusted workspace. <a href=\"command:workbench.action.manageTrustedDomain\">Manage trust</a></div>`;\n  }\n\n  const htmlParts: string[] = [];\n\n  const renderQueryResult = (qr: QueryResult): string => {\n    const desc = qr.descriptor;\n    const format =\n      desc.format ?? (desc.select && desc.select.length > 1 ? 'table' : 'list');\n    if (format === 'list') {\n      const listFields = desc.select ?? ['title'];\n      const needsPath =\n        listFields.includes('title') && !listFields.includes('path');\n      const data = needsPath\n        ? qr.select([...listFields, 'path']).toArray()\n        : qr.select(listFields).toArray();\n      return renderList(data, listFields, toRelativePath);\n    }\n    // Ensure path is fetched for link generation when title is selected,\n    // but pass the original descriptor to renderResults so path isn't shown as a column.\n    const needsPath =\n      desc.select &&\n      desc.select.includes('title') &&\n      !desc.select.includes('path');\n    const results = needsPath\n      ? qr.select([...desc.select, 'path']).toArray()\n      : qr.toArray();\n    return renderResults(results, desc, toRelativePath);\n  };\n\n  const render = (value: QueryResult | string | undefined | null) => {\n    if (value instanceof QueryResult) {\n      htmlParts.push(renderQueryResult(value));\n    } else if (value !== undefined && value !== null) {\n      htmlParts.push(`<p>${escapeHtml(String(value))}</p>`);\n    }\n  };\n\n  const sandbox: Record<string, unknown> = {};\n  BLOCKED_GLOBALS.forEach(g => {\n    sandbox[g] = undefined;\n  });\n  Object.assign(sandbox, {\n    Date,\n    Math,\n    Object,\n    Array,\n    String,\n    Number,\n    Boolean,\n    JSON,\n    RegExp,\n    Error,\n    console: {\n      log: (...args: unknown[]) =>\n        Logger.info(`[foam-query-js] ${args[0]}`, ...args.slice(1)),\n      warn: (...args: unknown[]) =>\n        Logger.warn(`[foam-query-js] ${args[0]}`, ...args.slice(1)),\n      error: (...args: unknown[]) =>\n        Logger.error(`[foam-query-js] ${args[0]}`, ...args.slice(1)),\n    },\n    dayjs,\n    slugify: toSlug,\n    URI,\n    render,\n    foam: {\n      pages: (filter?: QueryFilter | string) =>\n        new QueryResult(workspace, graph, trusted, filter as QueryFilter),\n    },\n  });\n\n  try {\n    const context = vm.createContext(sandbox);\n    new vm.Script(code).runInContext(context, { timeout: EXECUTION_TIMEOUT });\n  } catch (e) {\n    return `<div class=\"foam-query-error\">Script error: ${escapeHtml(\n      String(e)\n    )}</div>`;\n  }\n\n  return (\n    htmlParts.join('\\n') ||\n    '<p class=\"foam-query-empty\">No output. Did you forget to call render()?</p>'\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/attachment-provider.ts",
    "content": "import { Resource, ResourceLink } from '../model/note';\nimport { URI } from '../model/uri';\nimport { FoamWorkspace } from '../model/workspace';\nimport { IDisposable } from '../common/lifecycle';\nimport { ResourceProvider } from '../model/provider';\n\nexport const imageExtensions = [\n  '.png',\n  '.jpg',\n  '.jpeg',\n  '.gif',\n  '.svg',\n  '.webp',\n];\n\nconst asResource = (uri: URI): Resource => {\n  const type = imageExtensions.includes(uri.getExtension())\n    ? 'image'\n    : 'attachment';\n  return {\n    uri: uri,\n    title: uri.getBasename(),\n    type: type,\n    aliases: [],\n    properties: { type: type },\n    sections: [],\n    blocks: [],\n    links: [],\n    tags: [],\n  };\n};\n\nexport class AttachmentResourceProvider implements ResourceProvider {\n  private disposables: IDisposable[] = [];\n  public readonly attachmentExtensions: string[];\n\n  constructor(attachmentExtensions: string[] = []) {\n    this.attachmentExtensions = [...imageExtensions, ...attachmentExtensions];\n  }\n\n  supports(uri: URI) {\n    return this.attachmentExtensions.includes(\n      uri.getExtension().toLocaleLowerCase()\n    );\n  }\n\n  async readAsMarkdown(uri: URI): Promise<string | null> {\n    if (imageExtensions.includes(uri.getExtension())) {\n      return `![${''}](${uri.toString()}|height=200)`;\n    }\n    return `### ${uri.getBasename()}`;\n  }\n\n  async fetch(uri: URI) {\n    return asResource(uri);\n  }\n\n  resolveLink(w: FoamWorkspace, resource: Resource, l: ResourceLink) {\n    throw new Error('not supported');\n    // Silly workaround to make VS Code and es-lint happy\n    // eslint-disable-next-line\n    return resource.uri;\n  }\n\n  dispose() {\n    this.disposables.forEach(d => d.dispose());\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/datastore.test.ts",
    "content": "import { Matcher, toMatcherPathFormat } from '../../test/test-datastore';\nimport { TEST_DATA_DIR } from '../../test/test-utils';\nimport { URI } from '../model/uri';\nimport { Logger } from '../utils/log';\n\nLogger.setLevel('error');\n\nconst testFolder = TEST_DATA_DIR.joinPath('test-datastore');\n\ndescribe('Matcher', () => {\n  it('generates globs with the base dir provided', () => {\n    const matcher = new Matcher([testFolder], ['*'], []);\n    expect(matcher.folders).toEqual([toMatcherPathFormat(testFolder)]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('*')),\n    ]);\n  });\n\n  it('defaults to including everything and excluding nothing', () => {\n    const matcher = new Matcher([testFolder]);\n    expect(matcher.exclude).toEqual([]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('**', '*')),\n    ]);\n  });\n\n  it('supports multiple includes', () => {\n    const matcher = new Matcher([testFolder], ['g1', 'g2'], []);\n    expect(matcher.exclude).toEqual([]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('g1')),\n      toMatcherPathFormat(testFolder.joinPath('g2')),\n    ]);\n  });\n\n  it('has a match method to filter strings', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], []);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.match(files)).toEqual([\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n    ]);\n  });\n\n  it('has a isMatch method to see whether a file is matched or not', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], []);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.isMatch(files[0])).toEqual(true);\n    expect(matcher.isMatch(files[1])).toEqual(true);\n    expect(matcher.isMatch(files[2])).toEqual(false);\n    expect(matcher.isMatch(files[3])).toEqual(false);\n  });\n\n  it('happy path', () => {\n    const matcher = new Matcher([URI.file('/root/')], ['**/*'], ['**/*.pdf']);\n    expect(matcher.isMatch(URI.file('/root/file.md'))).toBeTruthy();\n    expect(matcher.isMatch(URI.file('/root/file.pdf'))).toBeFalsy();\n    expect(matcher.isMatch(URI.file('/root/dir/file.md'))).toBeTruthy();\n    expect(matcher.isMatch(URI.file('/root/dir/file.pdf'))).toBeFalsy();\n  });\n\n  it('ignores files in the exclude list', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], ['file1.*']);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.isMatch(files[0])).toEqual(false);\n    expect(matcher.isMatch(files[1])).toEqual(true);\n    expect(matcher.isMatch(files[2])).toEqual(false);\n    expect(matcher.isMatch(files[3])).toEqual(false);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/datastore.ts",
    "content": "import { URI } from '../model/uri';\nimport { Logger } from '../utils/log';\nimport { Event } from '../common/event';\n\n/**\n * Represents a source of files and content\n */\nexport interface IDataStore {\n  /**\n   * List the files matching the given glob from the\n   * store\n   */\n  list: () => Promise<URI[]>;\n\n  /**\n   * Read the content of the file from the store\n   *\n   * Returns `null` in case of errors while reading\n   */\n  read: (uri: URI) => Promise<string | null>;\n}\n\nexport interface IWatcher {\n  onDidChange: Event<URI>;\n  onDidCreate: Event<URI>;\n  onDidDelete: Event<URI>;\n}\n\nexport interface IMatcher {\n  /**\n   * Filters the given list of URIs, keepin only the ones that\n   * are matched by this Matcher\n   *\n   * @param files the URIs to check\n   */\n  match(files: URI[]): URI[];\n\n  /**\n   * Returns whether this URI is matched by this Matcher\n   *\n   * @param uri the URI to check\n   */\n  isMatch(uri: URI): boolean;\n\n  /**\n   * Refreshes the list of files that this matcher matches\n   * To be used when new files are added to the workspace,\n   * it can be a more or less expensive operation depending on the\n   * implementation of the matcher\n   */\n  refresh(): Promise<void>;\n\n  /**\n   * The include globs\n   */\n  include: string[];\n\n  /**\n   * The exclude lobs\n   */\n  exclude: string[];\n}\n\nexport class GenericDataStore implements IDataStore {\n  constructor(\n    private readonly listFiles: () => Promise<URI[]>,\n    private readFile: (uri: URI) => Promise<string>\n  ) {}\n\n  async list(): Promise<URI[]> {\n    return this.listFiles();\n  }\n\n  async read(uri: URI) {\n    try {\n      return await this.readFile(uri);\n    } catch (e) {\n      Logger.error(\n        `FileDataStore: error while reading uri: ${uri.path} - ${e}`\n      );\n      return null;\n    }\n  }\n}\n\n/**\n * A matcher that instead of using globs uses a list of files to\n * check the matches.\n * The {@link refresh} function has been added to the interface to accommodate\n * this matcher, far from ideal but to be refactored later\n */\nexport class FileListBasedMatcher implements IMatcher {\n  private files: string[] = [];\n  include: string[];\n  exclude: string[];\n\n  constructor(\n    files: URI[],\n    private readonly listFiles: () => Promise<URI[]>,\n    include: string[] = ['**/*'],\n    exclude: string[] = []\n  ) {\n    this.files = files.map(f => f.path);\n    this.include = include;\n    this.exclude = exclude;\n  }\n\n  match(files: URI[]): URI[] {\n    return files.filter(f => this.files.includes(f.path));\n  }\n\n  isMatch(uri: URI): boolean {\n    return this.files.includes(uri.path);\n  }\n\n  async refresh() {\n    this.files = (await this.listFiles()).map(f => f.path);\n  }\n\n  static async createFromListFn(\n    listFiles: () => Promise<URI[]>,\n    include: string[] = ['**/*'],\n    exclude: string[] = []\n  ) {\n    const files = await listFiles();\n    return new FileListBasedMatcher(files, listFiles, include, exclude);\n  }\n}\n\n/**\n * A matcher that includes all URIs passed to it\n */\nexport class AlwaysIncludeMatcher implements IMatcher {\n  include: string[] = ['**/*'];\n  exclude: string[] = [];\n  match(files: URI[]): URI[] {\n    return files;\n  }\n\n  isMatch(uri: URI): boolean {\n    return true;\n  }\n\n  refresh(): Promise<void> {\n    return;\n  }\n}\n\nexport class SubstringExcludeMatcher implements IMatcher {\n  include: string[] = ['**/*'];\n  exclude: string[] = [];\n  constructor(exclude: string) {\n    this.exclude = [exclude];\n  }\n\n  match(files: URI[]): URI[] {\n    return files.filter(f => this.isMatch(f));\n  }\n\n  isMatch(uri: URI): boolean {\n    return !uri.path.includes(this.exclude[0]);\n  }\n\n  refresh(): Promise<void> {\n    return;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/heading-edit.test.ts",
    "content": "import {\n  createNoteFromMarkdown,\n  createTestWorkspace,\n} from '../../test/test-utils';\nimport { FoamGraph } from '../model/graph';\nimport { HeadingEdit } from './heading-edit';\n\ndescribe('HeadingEdit', () => {\n  describe('createRenameBlockEdits', () => {\n    it('should update a wikilink with a block anchor reference', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `A paragraph ^oldblock`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `See [[note-a#^oldblock]] for details.`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'oldblock',\n        'newblock'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-b.md');\n      expect(result.edits[0].edit.newText).toBe('[[note-a#^newblock]]');\n    });\n\n    it('should update a self-referencing block link within the same document', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `A paragraph ^myblock\\n\\nJump to [[#^myblock]].`\n      );\n      ws.set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'myblock',\n        'renamedblock'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-a.md');\n      expect(result.edits[0].edit.newText).toBe('[[#^renamedblock]]');\n    });\n\n    it('should not update links that reference a different block', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `Para one ^block1\\n\\nPara two ^block2`\n      );\n      const noteB = createNoteFromMarkdown('/note-b.md', `[[note-a#^block2]]`);\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'block1',\n        'renamed'\n      );\n\n      expect(result.totalOccurrences).toBe(0);\n      expect(result.edits).toHaveLength(0);\n    });\n\n    it('should update block links across multiple files', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `A paragraph ^myblock`\n      );\n      const noteB = createNoteFromMarkdown('/note-b.md', `[[note-a#^myblock]]`);\n      const noteC = createNoteFromMarkdown('/note-c.md', `[[note-a#^myblock]]`);\n      ws.set(noteA).set(noteB).set(noteC);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'myblock',\n        'renamed'\n      );\n\n      expect(result.totalOccurrences).toBe(2);\n      expect(result.edits).toHaveLength(2);\n      const uris = result.edits.map(e => e.uri.path).sort();\n      expect(uris).toEqual(['/note-b.md', '/note-c.md']);\n    });\n\n    it('should update a direct markdown link with a block anchor reference', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `A paragraph ^oldblock`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `[link text](/note-a.md#^oldblock)`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'oldblock',\n        'newblock'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-b.md');\n      expect(result.edits[0].edit.newText).toContain('^newblock');\n      expect(result.edits[0].edit.newText).not.toContain('^oldblock');\n    });\n\n    it('should return empty result when no backlinks reference the block', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown('/note-a.md', `A paragraph ^orphan`);\n      ws.set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameBlockEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'orphan',\n        'renamed'\n      );\n\n      expect(result.totalOccurrences).toBe(0);\n      expect(result.edits).toHaveLength(0);\n    });\n  });\n\n  describe('createRenameSectionEdits', () => {\n    it('should update a wikilink with a section reference', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Old Section\n\nContent.\n`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `See [[note-a#Old Section]] for details.`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Old Section',\n        'New Section'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-b.md');\n      expect(result.edits[0].edit.newText).toBe('[[note-a#New Section]]');\n    });\n\n    it('should update a self-referencing section link within the same document', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Old Section\n\nJump to [[#Old Section]].\n`\n      );\n      ws.set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Old Section',\n        'New Section'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-a.md');\n      expect(result.edits[0].edit.newText).toBe('[[#New Section]]');\n    });\n\n    it('should update a direct markdown link with a section reference', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# OldSection\n\nContent.\n`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `[link text](/note-a.md#OldSection)`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'OldSection',\n        'NewSection'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].uri.path).toBe('/note-b.md');\n      expect(result.edits[0].edit.newText).toContain('NewSection');\n      expect(result.edits[0].edit.newText).not.toContain('OldSection');\n    });\n\n    it('should update the definition line for a resolved reference-style link', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# OldSection\n\nContent.\n`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `[see more][ref1]\n\n[ref1]: note-a#OldSection\n`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'OldSection',\n        'NewSection'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      const edit = result.edits[0];\n      expect(edit.uri.path).toBe('/note-b.md');\n      // The edit must target the definition line (line 2), not the inline link (line 0)\n      expect(edit.edit.range.start.line).toBe(2);\n      // The new text should be the reformatted definition with the updated URL\n      expect(edit.edit.newText).toBe('[ref1]: note-a#NewSection');\n      // The inline link text must not appear in the edit\n      expect(edit.edit.newText).not.toContain('see more');\n    });\n\n    it('should not update links that reference a different section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Section One\n\n## Section Two\n`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `[[note-a#Section Two]]`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Section One',\n        'Section Renamed'\n      );\n\n      expect(result.totalOccurrences).toBe(0);\n      expect(result.edits).toHaveLength(0);\n    });\n\n    it('should update links across multiple files', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Old Section\n\nContent.\n`\n      );\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `[[note-a#Old Section]]`\n      );\n      const noteC = createNoteFromMarkdown(\n        '/note-c.md',\n        `[[note-a#Old Section]]`\n      );\n      ws.set(noteA).set(noteB).set(noteC);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Old Section',\n        'New Section'\n      );\n\n      expect(result.totalOccurrences).toBe(2);\n      expect(result.edits).toHaveLength(2);\n      const uris = result.edits.map(e => e.uri.path).sort();\n      expect(uris).toEqual(['/note-b.md', '/note-c.md']);\n    });\n\n    it('should update the definition URL when a wikilink has no section in its identifier but its resolved definition references the section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# OldSection\n\nContent.\n`\n      );\n      // [[note-a]] has no section in rawText; section lives only in the definition URL\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `See [[note-a]] for details.\n\n[note-a]: note-a.md#OldSection \"Note A\"\n`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'OldSection',\n        'NewSection'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      const edit = result.edits[0];\n      expect(edit.uri.path).toBe('/note-b.md');\n      // Only the definition URL is updated; rawText [[note-a]] is left unchanged\n      expect(edit.edit.range.start.line).toBe(2);\n      expect(edit.edit.newText).toBe('[note-a]: note-a.md#NewSection \"Note A\"');\n    });\n\n    it('should update both the wikilink identifier and its definition when the identifier contains the section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# OldSection\n\nContent.\n`\n      );\n      // [[note-a#OldSection]] has section in rawText; the auto-generated definition mirrors it\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `See [[note-a#OldSection]] for details.\n\n[note-a#OldSection]: note-a.md#OldSection \"Note A\"\n`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'OldSection',\n        'NewSection'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(2);\n      const wikilinkEdit = result.edits.find(e =>\n        e.edit.newText.startsWith('[[')\n      );\n      const defEdit = result.edits.find(e => !e.edit.newText.startsWith('[['));\n      expect(wikilinkEdit?.edit.newText).toBe('[[note-a#NewSection]]');\n      expect(defEdit?.edit.range.start.line).toBe(2);\n      expect(defEdit?.edit.newText).toBe(\n        '[note-a#NewSection]: note-a.md#NewSection \"Note A\"'\n      );\n    });\n\n    it('should update both the wikilink identifier and its definition (angle-bracket URL) when the identifier contains the section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Old Section\n\nContent.\n`\n      );\n      // noteB simulates a file where Foam has auto-generated link references\n      // with the 'withExtensions' setting. Foam wraps URLs with spaces in angle\n      // brackets, producing a resolved definition for the wikilink.\n      const noteB = createNoteFromMarkdown(\n        '/note-b.md',\n        `See [[note-a#Old Section]] for details.\n\n[note-a#Old Section]: <note-a.md#Old Section> \"Note A\"\n`\n      );\n      ws.set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Old Section',\n        'New Section'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(2);\n      const wikilinkEdit = result.edits.find(e =>\n        e.edit.newText.startsWith('[[')\n      );\n      const defEdit = result.edits.find(e => !e.edit.newText.startsWith('[['));\n      // Must preserve 'note-a' (not 'note-a.md') as the wikilink target\n      expect(wikilinkEdit?.edit.newText).toBe('[[note-a#New Section]]');\n      expect(defEdit?.edit.range.start.line).toBe(2);\n      expect(defEdit?.edit.newText).toBe(\n        '[note-a#New Section]: <note-a.md#New Section> \"Note A\"'\n      );\n    });\n\n    it('should return empty result when no backlinks reference the section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        '/note-a.md',\n        `# Some Section\n\nContent.\n`\n      );\n      ws.set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const result = HeadingEdit.createRenameSectionEdits(\n        graph,\n        ws,\n        noteA.uri,\n        'Some Section',\n        'New Section'\n      );\n\n      expect(result.totalOccurrences).toBe(0);\n      expect(result.edits).toHaveLength(0);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/heading-edit.ts",
    "content": "import { FoamGraph } from '../model/graph';\nimport { FoamWorkspace } from '../model/workspace';\nimport { URI } from '../model/uri';\nimport { ResourceLink, NoteLinkDefinition } from '../model/note';\nimport { MarkdownLink } from './markdown-link';\nimport { WorkspaceTextEdit } from './text-edit';\n\nexport interface HeadingEditResult {\n  edits: WorkspaceTextEdit[];\n  totalOccurrences: number;\n}\n\nexport abstract class HeadingEdit {\n  /**\n   * Generate edits to update all links referencing block `^oldId` in the\n   * resource at `resourceUri`, renaming it to `^newId`.\n   *\n   * Does NOT include the edit to update the block anchor text itself — that is\n   * handled by the caller (the VS Code provider layer).\n   *\n   * Handles:\n   * - Wikilinks: `[[note#^oldId]]` → `[[note#^newId]]`\n   * - Direct markdown links: `[text](note.md#^oldId)` → `[text](note.md#^newId)`\n   * - Reference-style markdown links: updates the definition URL fragment\n   */\n  static createRenameBlockEdits(\n    graph: FoamGraph,\n    workspace: FoamWorkspace,\n    resourceUri: URI,\n    oldId: string,\n    newId: string\n  ): HeadingEditResult {\n    const backlinks = graph.getBacklinks(resourceUri);\n    const edits: WorkspaceTextEdit[] = [];\n    let totalOccurrences = 0;\n\n    for (const connection of backlinks) {\n      const link = connection.link;\n      let blockId: string;\n      try {\n        ({ blockId } = MarkdownLink.analyzeLink(link));\n      } catch {\n        continue;\n      }\n\n      if (link.type === 'link' && ResourceLink.isResolvedReference(link)) {\n        if (blockId !== oldId) {\n          continue;\n        }\n        const def = link.definition as NoteLinkDefinition;\n        if (!def.range) {\n          continue;\n        }\n        const hashIdx = def.url.lastIndexOf('#');\n        const newUrl =\n          hashIdx >= 0\n            ? def.url.slice(0, hashIdx + 1) + '^' + newId\n            : def.url + '#^' + newId;\n\n        totalOccurrences++;\n        edits.push({\n          uri: connection.source,\n          edit: {\n            range: def.range,\n            newText: NoteLinkDefinition.format({ ...def, url: newUrl }),\n          },\n        });\n      } else if (\n        link.type === 'wikilink' &&\n        ResourceLink.isResolvedReference(link)\n      ) {\n        const def = link.definition as NoteLinkDefinition;\n        const rawTextMatchesBlock = blockId === oldId;\n        const defHashIdx = def.url.lastIndexOf('#');\n        const defFragment =\n          defHashIdx >= 0 ? def.url.slice(defHashIdx + 1) : '';\n        const defBlockMatch = defFragment.match(/^\\^([a-zA-Z0-9-]+)$/);\n        const defBlockId = defBlockMatch?.[1] ?? '';\n        const defMatchesBlock = defBlockId === oldId;\n\n        if (!rawTextMatchesBlock && !defMatchesBlock) {\n          continue;\n        }\n\n        totalOccurrences++;\n\n        if (rawTextMatchesBlock) {\n          edits.push({\n            uri: connection.source,\n            edit: MarkdownLink.createUpdateLinkEdit(link, {\n              section: '^' + newId,\n            }),\n          });\n        }\n\n        if (defMatchesBlock && def.range) {\n          const newUrl = def.url.slice(0, defHashIdx + 1) + '^' + newId;\n          const labelHashIdx = def.label.lastIndexOf('#');\n          const labelFragment =\n            labelHashIdx >= 0 ? def.label.slice(labelHashIdx + 1) : '';\n          const labelBlockMatch = labelFragment.match(/^\\^([a-zA-Z0-9-]+)$/);\n          const newDefLabel =\n            labelBlockMatch?.[1] === oldId\n              ? def.label.slice(0, labelHashIdx + 1) + '^' + newId\n              : def.label;\n\n          edits.push({\n            uri: connection.source,\n            edit: {\n              range: def.range,\n              newText: NoteLinkDefinition.format({\n                ...def,\n                label: newDefLabel,\n                url: newUrl,\n              }),\n            },\n          });\n        }\n      } else {\n        if (blockId !== oldId) {\n          continue;\n        }\n        totalOccurrences++;\n        edits.push({\n          uri: connection.source,\n          edit: MarkdownLink.createUpdateLinkEdit(link, {\n            section: '^' + newId,\n          }),\n        });\n      }\n    }\n\n    return { edits, totalOccurrences };\n  }\n\n  /**\n   * Generate edits to update all links referencing `oldLabel` section in\n   * the resource at `resourceUri`, renaming it to `newLabel`.\n   *\n   * Does NOT include the edit to update the heading text itself — that is\n   * handled by the caller (the VS Code provider layer).\n   *\n   * Handles three link forms:\n   * - Wikilinks: `[[note#OldLabel]]` → `[[note#NewLabel]]`\n   * - Self-referencing wikilinks: `[[#OldLabel]]` → `[[#NewLabel]]`\n   * - Direct markdown links: `[text](note.md#OldLabel)` → `[text](note.md#NewLabel)`\n   * - Reference-style markdown links: updates the definition URL line, not the inline text\n   */\n  static createRenameSectionEdits(\n    graph: FoamGraph,\n    workspace: FoamWorkspace,\n    resourceUri: URI,\n    oldLabel: string,\n    newLabel: string\n  ): HeadingEditResult {\n    const backlinks = graph.getBacklinks(resourceUri);\n    const edits: WorkspaceTextEdit[] = [];\n    let totalOccurrences = 0;\n\n    for (const connection of backlinks) {\n      const link = connection.link;\n      let section: string;\n      try {\n        ({ section } = MarkdownLink.analyzeLink(link));\n      } catch {\n        continue;\n      }\n\n      // For resolved reference-style markdown links (e.g. `[text][ref]` with\n      // `[ref]: note.md#OldLabel`), update the definition line URL rather than\n      // the inline text, which doesn't contain the URL.\n      if (link.type === 'link' && ResourceLink.isResolvedReference(link)) {\n        if (section !== oldLabel) {\n          continue;\n        }\n        const def = link.definition as NoteLinkDefinition;\n        if (!def.range) {\n          continue;\n        }\n        const hashIdx = def.url.lastIndexOf('#');\n        const newUrl =\n          hashIdx >= 0\n            ? def.url.slice(0, hashIdx + 1) + newLabel\n            : def.url + '#' + newLabel;\n\n        totalOccurrences++;\n        edits.push({\n          uri: connection.source,\n          edit: {\n            range: def.range,\n            newText: NoteLinkDefinition.format({ ...def, url: newUrl }),\n          },\n        });\n      } else if (\n        link.type === 'wikilink' &&\n        ResourceLink.isResolvedReference(link)\n      ) {\n        // For resolved wikilinks, the section may appear in the rawText\n        // identifier, in the definition URL, or both.\n        //\n        // - If only in definition: update definition URL only (rawText has no\n        //   section to update, e.g. `[[note]]` + `[note]: note.md#OldLabel`)\n        // - If in both: update rawText and update definition URL + label\n        //   (e.g. `[[note#OldLabel]]` + `[note#OldLabel]: note.md#OldLabel`)\n        const def = link.definition as NoteLinkDefinition;\n        const rawTextMatchesSection = section === oldLabel;\n        const defHashIdx = def.url.lastIndexOf('#');\n        const defSection = defHashIdx >= 0 ? def.url.slice(defHashIdx + 1) : '';\n        const defMatchesSection = defSection === oldLabel;\n\n        if (!rawTextMatchesSection && !defMatchesSection) {\n          continue;\n        }\n\n        totalOccurrences++;\n\n        if (rawTextMatchesSection) {\n          const textEdit = MarkdownLink.createUpdateLinkEdit(link, {\n            section: newLabel,\n          });\n          edits.push({ uri: connection.source, edit: textEdit });\n        }\n\n        if (defMatchesSection && def.range) {\n          const newUrl = def.url.slice(0, defHashIdx + 1) + newLabel;\n          // If the definition label also encodes the section\n          // (e.g. \"note-a#OldLabel\"), update the label portion too.\n          const labelHashIdx = def.label.lastIndexOf('#');\n          const labelSection =\n            labelHashIdx >= 0 ? def.label.slice(labelHashIdx + 1) : '';\n          const newDefLabel =\n            labelSection === oldLabel\n              ? def.label.slice(0, labelHashIdx + 1) + newLabel\n              : def.label;\n\n          edits.push({\n            uri: connection.source,\n            edit: {\n              range: def.range,\n              newText: NoteLinkDefinition.format({\n                ...def,\n                label: newDefLabel,\n                url: newUrl,\n              }),\n            },\n          });\n        }\n      } else {\n        // Wikilinks without resolved definitions and regular inline markdown links.\n        if (section !== oldLabel) {\n          continue;\n        }\n        totalOccurrences++;\n        const textEdit = MarkdownLink.createUpdateLinkEdit(link, {\n          section: newLabel,\n        });\n        edits.push({ uri: connection.source, edit: textEdit });\n      }\n    }\n\n    return { edits, totalOccurrences };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-link.test.ts",
    "content": "import { getRandomURI } from '../../test/test-utils';\nimport { ResourceLink } from '../model/note';\nimport { Range } from '../model/range';\nimport { createMarkdownParser } from '../services/markdown-parser';\nimport { MarkdownLink } from './markdown-link';\n\ndescribe('MarkdownLink', () => {\n  const parser = createMarkdownParser([]);\n  describe('parse wikilink', () => {\n    it('should parse target', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[wikilink]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse target and section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink#section]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse target and alias', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('alias');\n    });\n    it('should parse links with square brackets #975', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink [with] brackets]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink [with] brackets');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse links with square brackets in alias #975', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink|alias [with] brackets]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('alias [with] brackets');\n    });\n    it('should parse target and alias with escaped separator', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink\\\\|alias]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('alias');\n    });\n    it('should parse target section and alias', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink with spaces#section with spaces|alias with spaces]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('wikilink with spaces');\n      expect(parsed.section).toEqual('section with spaces');\n      expect(parsed.alias).toEqual('alias with spaces');\n    });\n    it('should parse section', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[#section]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse block anchor', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[note#^myblock]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('note');\n      expect(parsed.section).toEqual('');\n      expect(parsed.blockId).toEqual('myblock');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse self-referencing block anchor', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[#^myblock]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('');\n      expect(parsed.section).toEqual('');\n      expect(parsed.blockId).toEqual('myblock');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse block anchor with alias', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[note#^myblock|My Alias]]`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('note');\n      expect(parsed.blockId).toEqual('myblock');\n      expect(parsed.alias).toEqual('My Alias');\n    });\n    it('should not treat ^ in the middle of section as a block anchor', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[note#foo^bar]]`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.section).toEqual('foo^bar');\n      expect(parsed.blockId).toEqual('');\n    });\n  });\n\n  describe('parse direct link', () => {\n    it('should parse target', () => {\n      const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('link');\n    });\n    it('should parse target and section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [link](to/path.md#section)`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('link');\n    });\n    it('should parse section only', () => {\n      const link: ResourceLink = {\n        type: 'link',\n        rawText: '[link](#section)',\n        range: Range.create(0, 0),\n        isEmbed: false,\n      };\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('link');\n    });\n    it('should parse links with square brackets in label #975', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [inbox [xyz]](to/path.md)`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('inbox [xyz]');\n    });\n    it('should parse links with empty label #975', () => {\n      const link = parser.parse(getRandomURI(), `this is a [](to/path.md)`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse links with angles #1039', () => {\n      const link = parser.parse(getRandomURI(), `this is a [](<to/path.md>)`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse links with angles and sections #1039', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [](<to/path.md#section>)`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('to/path.md');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('');\n    });\n    it('should parse block anchor from a direct markdown link', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [text](note.md#^myblock)`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('note.md');\n      expect(parsed.section).toEqual('');\n      expect(parsed.blockId).toEqual('myblock');\n      expect(parsed.alias).toEqual('text');\n    });\n    it('should not treat a regular section fragment as a block anchor in a direct link', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [text](note.md#section)`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.section).toEqual('section');\n      expect(parsed.blockId).toEqual('');\n    });\n  });\n\n  describe('parse direct link with title attributes', () => {\n    it('should parse image with double-quoted title', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt text](image.jpg \"Title text\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('image.jpg');\n      expect(parsed.alias).toEqual('alt text');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should parse image with single-quoted title', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt text](image.jpg 'Title text')`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('image.jpg');\n      expect(parsed.alias).toEqual('alt text');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should handle sections with titles', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt text](image.jpg#section \"Title text\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('image.jpg');\n      expect(parsed.section).toEqual('section');\n      expect(parsed.alias).toEqual('alt text');\n    });\n\n    it('should handle URLs with spaces in titles', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt](path/to/file.jpg \"Title with spaces\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('path/to/file.jpg');\n      expect(parsed.alias).toEqual('alt');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should maintain compatibility with titleless images', () => {\n      const link = parser.parse(getRandomURI(), `![alt text](image.jpg)`)\n        .links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('image.jpg');\n      expect(parsed.alias).toEqual('alt text');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should handle complex URLs with titles', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt](path/to/image.jpg \"Complex title with spaces\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('path/to/image.jpg');\n      expect(parsed.alias).toEqual('alt');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should parse regular links with titles', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `[link text](document.md \"Link title\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('document.md');\n      expect(parsed.alias).toEqual('link text');\n      expect(parsed.section).toEqual('');\n    });\n\n    it('should handle titles with special characters', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `![alt](image.jpg \"Title with special chars\")`\n      ).links[0];\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('image.jpg');\n      expect(parsed.alias).toEqual('alt');\n      expect(parsed.section).toEqual('');\n    });\n  });\n\n  describe('rename wikilink', () => {\n    it('should rename the target only', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink#section]]`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'new-link',\n      });\n      expect(edit.newText).toEqual(`[[new-link#section]]`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should rename the section only', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink#section]]`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        section: 'new-section',\n      });\n      expect(edit.newText).toEqual(`[[wikilink#new-section]]`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should rename both target and section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink#section]]`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'new-link',\n        section: 'new-section',\n      });\n      expect(edit.newText).toEqual(`[[new-link#new-section]]`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should be able to remove the section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [[wikilink#section]]`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        section: '',\n      });\n      expect(edit.newText).toEqual(`[[wikilink]]`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should be able to rename the alias', () => {\n      const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)\n        .links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        alias: 'new-alias',\n      });\n      expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);\n      expect(edit.range).toEqual(link.range);\n    });\n  });\n\n  describe('rename wikilink with block anchor', () => {\n    it('should preserve block anchor when renaming the target', () => {\n      const link = parser.parse(getRandomURI(), `[[note#^myblock]]`).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'new-note',\n      });\n      expect(edit.newText).toEqual(`[[new-note#^myblock]]`);\n    });\n  });\n\n  describe('rename direct link', () => {\n    it('should rename the target only', () => {\n      const link = parser.parse(getRandomURI(), `this is a [link](to/path.md)`)\n        .links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'to/another-path.md',\n      });\n      expect(edit.newText).toEqual(`[link](to/another-path.md)`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should rename the section only', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [link](to/path.md#section)`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        section: 'section2',\n      });\n      expect(edit.newText).toEqual(`[link](to/path.md#section2)`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should rename both target and section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [link](to/path.md#section)`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'to/another-path.md',\n        section: 'section2',\n      });\n      expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);\n      expect(edit.range).toEqual(link.range);\n    });\n    it('should be able to remove the section', () => {\n      const link = parser.parse(\n        getRandomURI(),\n        `this is a [link](to/path.md#section)`\n      ).links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        section: '',\n      });\n      expect(edit.newText).toEqual(`[link](to/path.md)`);\n      expect(edit.range).toEqual(link.range);\n    });\n  });\n\n  describe('rename direct link with block anchor', () => {\n    it('should preserve block anchor when renaming the target', () => {\n      const link = parser.parse(getRandomURI(), `[text](note.md#^myblock)`)\n        .links[0];\n      const edit = MarkdownLink.createUpdateLinkEdit(link, {\n        target: 'new-note.md',\n      });\n      expect(edit.newText).toEqual(`[text](new-note.md#^myblock)`);\n    });\n  });\n\n  describe('convert wikilink to link', () => {\n    it('should generate default alias if no one', () => {\n      const wikilink = parser.parse(getRandomURI(), `[[wikilink]]`).links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        type: 'link',\n      });\n      expect(wikilinkEdit.newText).toEqual(`[wikilink](wikilink)`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n\n      const wikilinkWithSection = parser.parse(\n        getRandomURI(),\n        `[[wikilink#section]]`\n      ).links[0];\n      const wikilinkWithSectionEdit = MarkdownLink.createUpdateLinkEdit(\n        wikilinkWithSection,\n        {\n          type: 'link',\n        }\n      );\n      expect(wikilinkWithSectionEdit.newText).toEqual(\n        `[wikilink#section](wikilink#section)`\n      );\n      expect(wikilinkWithSectionEdit.range).toEqual(wikilinkWithSection.range);\n    });\n\n    it('should use alias in the wikilik the if there has one', () => {\n      const wikilink = parser.parse(\n        getRandomURI(),\n        `[[wikilink#section|alias]]`\n      ).links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        type: 'link',\n      });\n      expect(wikilinkEdit.newText).toEqual(`[alias](wikilink#section)`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n    });\n  });\n\n  describe('convert link to wikilink', () => {\n    it('should reorganize target, section, and alias in wikilink manner', () => {\n      const link = parser.parse(getRandomURI(), `[link](to/path.md)`).links[0];\n      const linkEdit = MarkdownLink.createUpdateLinkEdit(link, {\n        type: 'wikilink',\n      });\n      expect(linkEdit.newText).toEqual(`[[to/path.md|link]]`);\n      expect(linkEdit.range).toEqual(link.range);\n\n      const linkWithSection = parser.parse(\n        getRandomURI(),\n        `[link](to/path.md#section)`\n      ).links[0];\n      const linkWithSectionEdit = MarkdownLink.createUpdateLinkEdit(\n        linkWithSection,\n        {\n          type: 'wikilink',\n        }\n      );\n      expect(linkWithSectionEdit.newText).toEqual(\n        `[[to/path.md#section|link]]`\n      );\n      expect(linkWithSectionEdit.range).toEqual(linkWithSection.range);\n    });\n\n    it('should use alias in the wikilik the if there has one', () => {\n      const wikilink = parser.parse(\n        getRandomURI(),\n        `[[wikilink#section|alias]]`\n      ).links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        type: 'link',\n      });\n      expect(wikilinkEdit.newText).toEqual(`[alias](wikilink#section)`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n    });\n  });\n\n  describe('convert to its original type', () => {\n    it('should remain unchanged', () => {\n      const link = parser.parse(getRandomURI(), `[link](to/path.md#section)`)\n        .links[0];\n      const linkEdit = MarkdownLink.createUpdateLinkEdit(link, {\n        type: 'link',\n      });\n      expect(linkEdit.newText).toEqual(`[link](to/path.md#section)`);\n      expect(linkEdit.range).toEqual(link.range);\n\n      const wikilink = parser.parse(\n        getRandomURI(),\n        `[[wikilink#section|alias]]`\n      ).links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        type: 'wikilink',\n      });\n      expect(wikilinkEdit.newText).toEqual(`[[wikilink#section|alias]]`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n    });\n  });\n\n  describe('change isEmbed property', () => {\n    it('should change isEmbed only', () => {\n      const wikilink = parser.parse(getRandomURI(), `[[wikilink]]`).links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        isEmbed: true,\n      });\n      expect(wikilinkEdit.newText).toEqual(`![[wikilink]]`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n\n      const link = parser.parse(getRandomURI(), `![link](to/path.md)`).links[0];\n      const linkEdit = MarkdownLink.createUpdateLinkEdit(link, {\n        isEmbed: false,\n      });\n      expect(linkEdit.newText).toEqual(`[link](to/path.md)`);\n      expect(linkEdit.range).toEqual(link.range);\n    });\n\n    it('should be unchanged if the update value is the same as the original one', () => {\n      const embeddedWikilink = parser.parse(getRandomURI(), `![[wikilink]]`)\n        .links[0];\n      const embeddedWikilinkEdit = MarkdownLink.createUpdateLinkEdit(\n        embeddedWikilink,\n        {\n          isEmbed: true,\n        }\n      );\n      expect(embeddedWikilinkEdit.newText).toEqual(`![[wikilink]]`);\n      expect(embeddedWikilinkEdit.range).toEqual(embeddedWikilink.range);\n\n      const link = parser.parse(getRandomURI(), `[link](to/path.md)`).links[0];\n      const linkEdit = MarkdownLink.createUpdateLinkEdit(link, {\n        isEmbed: false,\n      });\n      expect(linkEdit.newText).toEqual(`[link](to/path.md)`);\n      expect(linkEdit.range).toEqual(link.range);\n    });\n  });\n\n  describe('insert angles', () => {\n    it('should insert angles when meeting space in links', () => {\n      const link = parser.parse(getRandomURI(), `![link](to/path.md)`).links[0];\n      const linkAddSection = MarkdownLink.createUpdateLinkEdit(link, {\n        section: 'one section',\n      });\n      expect(linkAddSection.newText).toEqual(\n        `![link](<to/path.md#one section>)`\n      );\n      expect(linkAddSection.range).toEqual(link.range);\n\n      const linkChangingTarget = parser.parse(\n        getRandomURI(),\n        `[link](to/path.md#one-section)`\n      ).links[0];\n      const linkEdit = MarkdownLink.createUpdateLinkEdit(linkChangingTarget, {\n        target: 'to/another path.md',\n      });\n      expect(linkEdit.newText).toEqual(\n        `[link](<to/another path.md#one-section>)`\n      );\n      expect(linkEdit.range).toEqual(linkChangingTarget.range);\n\n      const wikilink = parser.parse(getRandomURI(), `[[wikilink#one section]]`)\n        .links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        type: 'link',\n      });\n      expect(wikilinkEdit.newText).toEqual(\n        `[wikilink#one section](<wikilink#one section>)`\n      );\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n    });\n\n    it('should not insert angles in wikilink', () => {\n      const wikilink = parser.parse(getRandomURI(), `[[wikilink#one section]]`)\n        .links[0];\n      const wikilinkEdit = MarkdownLink.createUpdateLinkEdit(wikilink, {\n        target: 'another wikilink',\n      });\n      expect(wikilinkEdit.newText).toEqual(`[[another wikilink#one section]]`);\n      expect(wikilinkEdit.range).toEqual(wikilink.range);\n    });\n  });\n\n  describe('parse links with resolved definitions', () => {\n    it('should parse wikilink with resolved definition - target and section always from rawText', () => {\n      const link: ResourceLink = {\n        type: 'wikilink',\n        rawText: '[[my-note|Custom Display Text]]',\n        range: Range.create(0, 0),\n        isEmbed: false,\n        definition: {\n          label: 'my-note',\n          url: './docs/document.md#introduction',\n          title: 'Document Title',\n        },\n      };\n\n      const parsed = MarkdownLink.analyzeLink(link);\n      // Wikilinks are always parsed from rawText; the resolved definition is ignored\n      expect(parsed.target).toEqual('my-note');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('Custom Display Text');\n    });\n\n    it('should parse reference-style link with resolved definition - target and section from definition, alias from rawText', () => {\n      const link: ResourceLink = {\n        type: 'link',\n        rawText: '[Click here to read][myref]',\n        range: Range.create(0, 0),\n        isEmbed: false,\n        definition: {\n          label: 'myref',\n          url: './document.md#section',\n          title: 'My Document',\n        },\n      };\n\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('./document.md'); // From definition.url (base)\n      expect(parsed.section).toEqual('section'); // From definition.url (fragment)\n      expect(parsed.alias).toEqual('Click here to read'); // From rawText\n    });\n\n    it('should handle wikilink with resolved definition - section always from rawText', () => {\n      const link: ResourceLink = {\n        type: 'wikilink',\n        rawText: '[[my-note#ignored-section|Display Text]]',\n        range: Range.create(0, 0),\n        isEmbed: false,\n        definition: {\n          label: 'my-note',\n          url: './docs/document.md', // No fragment\n          title: 'Document Title',\n        },\n      };\n\n      const parsed = MarkdownLink.analyzeLink(link);\n      // Wikilinks are always parsed from rawText; the resolved definition is ignored\n      expect(parsed.target).toEqual('my-note');\n      expect(parsed.section).toEqual('ignored-section');\n      expect(parsed.alias).toEqual('Display Text');\n    });\n\n    it('should handle reference-style link with resolved definition but no alias in rawText', () => {\n      const link: ResourceLink = {\n        type: 'link',\n        rawText: '[text][ref]',\n        range: Range.create(0, 0),\n        isEmbed: false,\n        definition: {\n          label: 'ref',\n          url: './target.md#section',\n          title: 'Target',\n        },\n      };\n\n      const parsed = MarkdownLink.analyzeLink(link);\n      expect(parsed.target).toEqual('./target.md'); // From definition.url (base)\n      expect(parsed.section).toEqual('section'); // From definition.url (fragment)\n      expect(parsed.alias).toEqual('text'); // From rawText\n    });\n\n    it('should handle wikilink with complex URL in definition - always from rawText', () => {\n      const link: ResourceLink = {\n        type: 'wikilink',\n        rawText: '[[note|Alias]]',\n        range: Range.create(0, 0),\n        isEmbed: false,\n        definition: {\n          label: 'note',\n          url: '../path/to/some file.md#complex section name',\n          title: 'Title',\n        },\n      };\n\n      const parsed = MarkdownLink.analyzeLink(link);\n      // Wikilinks are always parsed from rawText; the resolved definition is ignored\n      expect(parsed.target).toEqual('note');\n      expect(parsed.section).toEqual('');\n      expect(parsed.alias).toEqual('Alias');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-link.ts",
    "content": "import { ResourceLink } from '../model/note';\nimport { URI } from '../model/uri';\nimport { TextEdit } from './text-edit';\n\nexport abstract class MarkdownLink {\n  private static wikilinkRegex = new RegExp(\n    /\\[\\[([^#|]+)?#?([^|]+)?\\|?(.*)?\\]\\]/\n  );\n  private static directLinkRegex = new RegExp(\n    /\\[(.*)\\]\\(<?([^#>]*?)(?:#([^>\\s\"'()]*))?(?:\\s+(?:\"[^\"]*\"|'[^']*'))?>?\\)/\n  );\n\n  public static analyzeLink(link: ResourceLink) {\n    try {\n      if (link.type === 'wikilink') {\n        // Wikilinks are always parsed from rawText. Any resolved definition is a\n        // Foam-generated rendering artifact, not authoritative content — the user's\n        // intent is expressed by the wikilink identifier itself.\n        const [, target, section, alias] = this.wikilinkRegex.exec(\n          link.rawText\n        );\n        // A fragment starting with ^ is a block anchor (e.g. #^myblock), not a section\n        const blockMatch = section?.match(/^\\^([a-zA-Z0-9-]+)$/);\n        return {\n          target: target?.replace(/\\\\/g, '') ?? '',\n          section: blockMatch ? '' : section ?? '',\n          blockId: blockMatch?.[1] ?? '',\n          alias: alias ?? '',\n        };\n      }\n      if (link.type === 'link') {\n        // For reference-style links with resolved definitions, parse target and section from definition URL\n        if (ResourceLink.isResolvedReference(link)) {\n          // Extract alias from rawText for reference-style links\n          const referenceMatch = /^\\[([^\\]]*)\\]/.exec(link.rawText);\n          const alias = referenceMatch ? referenceMatch[1] : '';\n\n          // Parse target and section from definition URL\n          const definitionUri = URI.parse(link.definition.url, 'tmp');\n          const defFragment = definitionUri.fragment;\n          const defBlockMatch = defFragment?.match(/^\\^([a-zA-Z0-9-]+)$/);\n          return {\n            target: definitionUri.path, // Base path from definition\n            section: defBlockMatch ? '' : defFragment ?? '',\n            blockId: defBlockMatch?.[1] ?? '',\n            alias: alias, // Alias from rawText\n          };\n        }\n\n        const match = this.directLinkRegex.exec(link.rawText);\n        if (!match) {\n          // This might be a reference-style link that wasn't resolved\n          // Try to extract just the alias text for reference-style links\n          const referenceMatch = /^\\[([^\\]]*)\\]/.exec(link.rawText);\n          const alias = referenceMatch ? referenceMatch[1] : '';\n          return {\n            target: '',\n            section: '',\n            blockId: '',\n            alias: alias,\n          };\n        }\n        const [, alias, target, section] = match;\n        const blockMatch = section?.match(/^\\^([a-zA-Z0-9-]+)$/);\n        return {\n          target: target ?? '',\n          section: blockMatch ? '' : section ?? '',\n          blockId: blockMatch?.[1] ?? '',\n          alias: alias ?? '',\n        };\n      }\n      throw new Error(`Link of type ${link.type} is not supported`);\n    } catch (e) {\n      throw new Error(`Couldn't parse link ${link.rawText} - ${e}`);\n    }\n  }\n\n  public static createUpdateLinkEdit(\n    link: ResourceLink,\n    delta: {\n      target?: string;\n      section?: string;\n      alias?: string;\n      type?: 'wikilink' | 'link';\n      isEmbed?: boolean;\n    }\n  ): TextEdit {\n    const { target, section, blockId, alias } = MarkdownLink.analyzeLink(link);\n    const newTarget = delta.target ?? target;\n    // Preserve the existing fragment (section or block anchor) when not overriding.\n    const existingFragment = blockId ? `^${blockId}` : section;\n    const newSection = delta.section ?? existingFragment ?? '';\n    const newAlias = delta.alias ?? alias ?? '';\n    const sectionDivider = newSection ? '#' : '';\n    const aliasDivider = newAlias ? '|' : '';\n    const embed = delta.isEmbed ?? link.isEmbed ? '!' : '';\n    const type = delta.type ?? link.type;\n    if (type === 'wikilink') {\n      return {\n        newText: `${embed}[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,\n        range: link.range,\n      };\n    }\n    if (type === 'link') {\n      const defaultAlias = () => {\n        return `${newTarget}${sectionDivider}${newSection}`;\n      };\n      const useAngles =\n        newTarget.indexOf(' ') > 0 || newSection.indexOf(' ') > 0;\n      return {\n        newText: `${embed}[${newAlias ? newAlias : defaultAlias()}](${\n          useAngles ? '<' : ''\n        }${newTarget}${sectionDivider}${newSection}${useAngles ? '>' : ''})`,\n        range: link.range,\n      };\n    }\n    throw new Error(`Unexpected state: link of type ${type} is not supported`);\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-parser.test.ts",
    "content": "import {\n  createMarkdownParser,\n  getBlockFor,\n  ParserPlugin,\n} from './markdown-parser';\nimport { NoteLinkDefinition, Resource, ResourceLink } from '../model/note';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport { Range } from '../model/range';\nimport { getRandomURI } from '../../test/test-utils';\nimport { Position } from '../model/position';\n\nLogger.setLevel('error');\n\nconst parser = createMarkdownParser([]);\nconst createNoteFromMarkdown = (content: string, path?: string) =>\n  parser.parse(path ? URI.file(path) : getRandomURI(), content);\n\ndescribe('Markdown parsing', () => {\n  it('should create a Resource from a markdown file', () => {\n    const note = createNoteFromMarkdown('Note content', '/a/path.md');\n    expect(note.uri).toEqual(URI.file('/a/path.md'));\n  });\n\n  describe('Links', () => {\n    it('should skip external links', () => {\n      const note = createNoteFromMarkdown(\n        `this is a [link to google](https://www.google.com)`\n      );\n      expect(note.links.length).toEqual(0);\n    });\n\n    it('should skip links to a section within the file', () => {\n      const note = createNoteFromMarkdown(\n        `this is a [link to intro](#introduction)`\n      );\n      expect(note.links.length).toEqual(0);\n    });\n\n    it('should detect regular markdown links', () => {\n      const note = createNoteFromMarkdown(\n        'this is a [link to page b](../doc/page-b.md)'\n      );\n      expect(note.links.length).toEqual(1);\n      const link = note.links[0];\n      expect(link.type).toEqual('link');\n      expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');\n      expect(link.isEmbed).toBeFalsy();\n    });\n\n    it('should detect links that have formatting in label', () => {\n      const note = createNoteFromMarkdown(\n        'this is [**link** with __formatting__](../doc/page-b.md)'\n      );\n      expect(note.links.length).toEqual(1);\n      const link = note.links[0];\n      expect(link.type).toEqual('link');\n      expect(link.isEmbed).toBeFalsy();\n    });\n\n    it('should detect embed links', () => {\n      const note = createNoteFromMarkdown('this is ![link](../doc/page-b.md)');\n      expect(note.links.length).toEqual(1);\n      const link = note.links[0];\n      expect(link.type).toEqual('link');\n      expect(link.isEmbed).toBeTruthy();\n    });\n\n    it('should detect wikilinks', () => {\n      const note = createNoteFromMarkdown(\n        'Some content and [[a link]] to [[a file]]'\n      );\n      expect(note.links.length).toEqual(2);\n      let link = note.links[0];\n      expect(link.type).toEqual('wikilink');\n      expect(link.rawText).toEqual('[[a link]]');\n      link = note.links[1];\n      expect(link.type).toEqual('wikilink');\n      expect(link.rawText).toEqual('[[a file]]');\n      expect(link.isEmbed).toBeFalsy();\n    });\n\n    it('should detect wikilink embeds', () => {\n      const note = createNoteFromMarkdown('Some content and ![[an embed]]');\n      expect(note.links.length).toEqual(1);\n      const link = note.links[0];\n      expect(link.type).toEqual('wikilink');\n      expect(link.rawText).toEqual('![[an embed]]');\n      expect(link.isEmbed).toBeTruthy();\n    });\n\n    it('should detect wikilinks that have aliases', () => {\n      const note = createNoteFromMarkdown(\n        'this is [[link|link alias]]. A link with spaces [[other link | spaced]]'\n      );\n      expect(note.links.length).toEqual(2);\n      let link = note.links[0];\n      expect(link.type).toEqual('wikilink');\n      expect(link.rawText).toEqual('[[link|link alias]]');\n      link = note.links[1];\n      expect(link.type).toEqual('wikilink');\n      expect(link.rawText).toEqual('[[other link | spaced]]');\n      expect(link.isEmbed).toBeFalsy();\n    });\n\n    it('should set reference to alias for wikilinks with alias', () => {\n      const note = createNoteFromMarkdown(\n        'This is a [[target-file|Display Name]] wikilink.'\n      );\n      expect(note.links.length).toEqual(1);\n      const link = note.links[0];\n      expect(link.type).toEqual('wikilink');\n      expect(ResourceLink.isUnresolvedReference(link)).toBe(true);\n      expect(link.definition).toEqual('target-file');\n    });\n\n    it('should skip wikilinks in codeblocks', () => {\n      const noteA = createNoteFromMarkdown(`\nthis is some text with our [[first-wikilink]].\n\n\\`\\`\\`\nthis is inside a [[codeblock]]\n\\`\\`\\`\n\nthis is some text with our [[second-wikilink]].\n    `);\n      expect(noteA.links.map(l => l.rawText)).toEqual([\n        '[[first-wikilink]]',\n        '[[second-wikilink]]',\n      ]);\n    });\n\n    it('should skip wikilinks in inlined codeblocks', () => {\n      const noteA = createNoteFromMarkdown(`\nthis is some text with our [[first-wikilink]].\n\nthis is \\`inside a [[codeblock]]\\`\n\nthis is some text with our [[second-wikilink]].\n    `);\n      expect(noteA.links.map(l => l.rawText)).toEqual([\n        '[[first-wikilink]]',\n        '[[second-wikilink]]',\n      ]);\n    });\n\n    it('#1545 - should not detect single brackets as links', () => {\n      const note = createNoteFromMarkdown(`\n\"She said [winning the award] was her best year.\"\n\nWe use brackets ([ and ]) to surround links.\n\nThis is not an easy task.[^1]\n\n[^1]: It would be easier if more papers were well written.\n      `);\n      expect(note.links.length).toEqual(0);\n    });\n\n    it('should detect reference-style links', () => {\n      const note = createNoteFromMarkdown(`\n# Test Document\n\nThis is a [reference-style link][ref1] and another [link][ref2].\n\n[ref1]: target1.md \"Target 1\"\n[ref2]: target2.md \"Target 2\"\n      `);\n\n      expect(note.links.length).toEqual(2);\n\n      const link1 = note.links[0];\n      expect(link1.type).toEqual('link');\n      expect(link1.rawText).toEqual('[reference-style link][ref1]');\n      expect(ResourceLink.isResolvedReference(link1)).toBe(true);\n      const definition1 = link1.definition as NoteLinkDefinition;\n      expect(definition1.label).toEqual('ref1');\n      expect(definition1.url).toEqual('target1.md');\n      expect(definition1.title).toEqual('Target 1');\n\n      const link2 = note.links[1];\n      expect(link2.type).toEqual('link');\n      expect(link2.rawText).toEqual('[link][ref2]');\n      expect(ResourceLink.isResolvedReference(link2)).toBe(true);\n      const definition2 = link2.definition as NoteLinkDefinition;\n      expect(definition2.label).toEqual('ref2');\n      expect(definition2.url).toEqual('target2.md');\n    });\n\n    it('should handle reference-style links without matching definitions', () => {\n      const note = createNoteFromMarkdown(`\nThis is a [reference-style link][missing-ref].\n\n[existing-ref]: target.md \"Target\"\n      `);\n\n      // Per CommonMark spec, reference links without matching definitions\n      // should be treated as plain text, not as links\n      expect(note.links.length).toEqual(0);\n    });\n\n    it('should handle mixed link types', () => {\n      const note = createNoteFromMarkdown(`\nThis has [[wikilink]], [inline link](target.md), and [reference link][ref].\n\n[ref]: reference-target.md \"Reference Target\"\n      `);\n\n      expect(note.links.length).toEqual(3);\n\n      expect(note.links[0].type).toEqual('wikilink');\n      expect(note.links[0].rawText).toEqual('[[wikilink]]');\n      expect(ResourceLink.isUnresolvedReference(note.links[0])).toBe(true);\n      expect(note.links[0].definition).toEqual('wikilink');\n\n      expect(note.links[1].type).toEqual('link');\n      expect(note.links[1].rawText).toEqual('[inline link](target.md)');\n      expect(ResourceLink.isReferenceStyleLink(note.links[1])).toBe(false);\n\n      expect(note.links[2].type).toEqual('link');\n      expect(note.links[2].rawText).toEqual('[reference link][ref]');\n      expect(ResourceLink.isResolvedReference(note.links[2])).toBe(true);\n    });\n  });\n\n  describe('Note Title', () => {\n    it('should initialize note title if heading exists', () => {\n      const note = createNoteFromMarkdown(`\n# Page A\nthis note has a title\n    `);\n      expect(note.title).toBe('Page A');\n    });\n\n    it('should support wikilinks and urls in title', () => {\n      const note = createNoteFromMarkdown(`\n# Page A with [[wikilink]] and a [url](https://google.com)\nthis note has a title\n    `);\n      expect(note.title).toBe('Page A with wikilink and a url');\n    });\n\n    it('should default to file name if heading does not exist', () => {\n      const note = createNoteFromMarkdown(\n        `This file has no heading.`,\n        '/page-d.md'\n      );\n\n      expect(note.title).toEqual('page-d');\n    });\n\n    it('should give precedence to frontmatter title over other headings', () => {\n      const note = createNoteFromMarkdown(`\n---\ntitle: Note Title\ndate: 20-12-12\n---\n\n# Other Note Title\n    `);\n\n      expect(note.title).toBe('Note Title');\n    });\n\n    it('should support numbers as title', () => {\n      const note1 = createNoteFromMarkdown(`hello`, '/157.md');\n      expect(note1.title).toBe('157');\n\n      const note2 = createNoteFromMarkdown(`# 158`, '/157.md');\n      expect(note2.title).toBe('158');\n\n      const note3 = createNoteFromMarkdown(\n        `\n---\ntitle: 159\n---\n\n# 158\n`,\n        '/157.md'\n      );\n      expect(note3.title).toBe('159');\n    });\n\n    it('should support empty titles (see #276)', () => {\n      const note = createNoteFromMarkdown(\n        `\n#\n\nthis note has an empty title line\n    `,\n        '/Hello Page.md'\n      );\n      expect(note.title).toEqual('Hello Page');\n    });\n  });\n\n  describe('Frontmatter', () => {\n    it('should parse yaml frontmatter', () => {\n      const note = createNoteFromMarkdown(`\n---\ntitle: Note Title\ndate: 20-12-12\n---\n\n# Other Note Title`);\n\n      expect(note.properties.title).toBe('Note Title');\n      expect(note.properties.date).toBe('20-12-12');\n    });\n\n    it('should parse empty frontmatter', () => {\n      const note = createNoteFromMarkdown(`\n---\n---\n\n# Empty Frontmatter\n`);\n\n      expect(note.properties).toEqual({});\n    });\n\n    it('should not fail when there are issues with parsing frontmatter', () => {\n      const note = createNoteFromMarkdown(`\n---\ntitle: - one\n - two\n - #\n---\n\n`);\n\n      expect(note.properties).toEqual({});\n    });\n\n    it('#1467 - should parse yaml frontmatter with colon in value', () => {\n      const note = createNoteFromMarkdown(`\n---\ntags: test\nsource: https://example.com/page:123\n---\n\n# Note with colon in meta value\\n`);\n      expect(note.properties.source).toBe('https://example.com/page:123');\n      expect(note.tags[0].label).toEqual('test');\n    });\n\n    it('#1455 - should parse tags when another field has a datetime value with colons', () => {\n      const note = createNoteFromMarkdown(`\n---\ndate: 2025-04-11T00:01:00+01:00\ntags:\n    - new\n---\n`);\n      expect(note.tags.map(t => t.label)).toContain('new');\n    });\n  });\n\n  describe('Tags', () => {\n    it('can find tags in the text of the note', () => {\n      const noteA = createNoteFromMarkdown(`\n# this is a #heading\n#this is some #text that includes #tags we #care-about.\n    `);\n      expect(noteA.tags).toEqual([\n        { label: 'heading', range: Range.create(1, 12, 1, 20) },\n        { label: 'this', range: Range.create(2, 0, 2, 5) },\n        { label: 'text', range: Range.create(2, 14, 2, 19) },\n        { label: 'tags', range: Range.create(2, 34, 2, 39) },\n        { label: 'care-about', range: Range.create(2, 43, 2, 54) },\n      ]);\n    });\n\n    it('will skip tags in codeblocks', () => {\n      const noteA = createNoteFromMarkdown(`\nthis is some #text that includes #tags we #care-about.\n\n\\`\\`\\`\nthis is a #codeblock\n\\`\\`\\`\n    `);\n      expect(noteA.tags.map(t => t.label)).toEqual([\n        'text',\n        'tags',\n        'care-about',\n      ]);\n    });\n\n    it('will skip tags in inlined codeblocks', () => {\n      const noteA = createNoteFromMarkdown(`\nthis is some #text that includes #tags we #care-about.\nthis is a \\`inlined #codeblock\\` `);\n      expect(noteA.tags.map(t => t.label)).toEqual([\n        'text',\n        'tags',\n        'care-about',\n      ]);\n    });\n    it('can find tags as text in yaml', () => {\n      const noteA = createNoteFromMarkdown(`\n---\ntags: hello, world  this_is_good\n---\n# this is a heading\nthis is some #text that includes #tags we #care-about.\n    `);\n      expect(noteA.tags.map(t => t.label)).toEqual([\n        'hello',\n        'world',\n        'this_is_good',\n        'text',\n        'tags',\n        'care-about',\n      ]);\n    });\n\n    it('can find tags as array in yaml', () => {\n      const noteA = createNoteFromMarkdown(`\n---\ntags: [hello, world,  this_is_good]\n---\n# this is a heading\nthis is some #text that includes #tags we #care-about.\n    `);\n      expect(noteA.tags.map(t => t.label)).toEqual([\n        'hello',\n        'world',\n        'this_is_good',\n        'text',\n        'tags',\n        'care-about',\n      ]);\n    });\n\n    it('provides a specific range for tags in yaml', () => {\n      // For now it's enough to just get the YAML block range\n      // in the future we might want to be more specific\n\n      const noteA = createNoteFromMarkdown(`\n---\nprop: hello world\ntags: [hello, world, this_is_good]\nanother: i love the world\n---\n# this is a heading\nthis is some text\n    `);\n      expect(noteA.tags[0]).toEqual({\n        label: 'hello',\n        range: Range.create(3, 7, 3, 12),\n      });\n      expect(noteA.tags[1]).toEqual({\n        label: 'world',\n        range: Range.create(3, 14, 3, 19),\n      });\n      expect(noteA.tags[2]).toEqual({\n        label: 'this_is_good',\n        range: Range.create(3, 21, 3, 33),\n      });\n\n      const noteB = createNoteFromMarkdown(`\n---\nprop: hello world\ntags: \n- hello\n- world\n- this_is_good\nanother: i love the world\n---\n# this is a heading\nthis is some text\n            `);\n      expect(noteB.tags[0]).toEqual({\n        label: 'hello',\n        range: Range.create(4, 2, 4, 7),\n      });\n      expect(noteB.tags[1]).toEqual({\n        label: 'world',\n        range: Range.create(5, 2, 5, 7),\n      });\n      expect(noteB.tags[2]).toEqual({\n        label: 'this_is_good',\n        range: Range.create(6, 2, 6, 14),\n      });\n    });\n  });\n\n  describe('Sections', () => {\n    it('should find sections within the note', () => {\n      const note = createNoteFromMarkdown(`\n# Section 1\n\nThis is the content of section 1.\n\n## Section 1.1\n\nThis is the content of section 1.1.\n\n# Section 2\n\nThis is the content of section 2.\n      `);\n      expect(note.sections).toHaveLength(3);\n      expect(note.sections[0].label).toEqual('Section 1');\n      expect(note.sections[0].range).toEqual(Range.create(1, 0, 9, 0));\n      expect(note.sections[1].label).toEqual('Section 1.1');\n      expect(note.sections[1].range).toEqual(Range.create(5, 0, 9, 0));\n      expect(note.sections[2].label).toEqual('Section 2');\n      expect(note.sections[2].range).toEqual(Range.create(9, 0, 13, 0));\n    });\n\n    it('should support wikilinks and links in the section label', () => {\n      const note = createNoteFromMarkdown(`\n# Section with [[wikilink]]\n\nThis is the content of section with wikilink\n\n## Section with [url](https://google.com)\n\nThis is the content of section with url`);\n      expect(note.sections).toHaveLength(2);\n      expect(note.sections[0].label).toEqual('Section with wikilink');\n      expect(note.sections[1].label).toEqual('Section with url');\n    });\n  });\n\n  describe('Parser plugins', () => {\n    const testPlugin: ParserPlugin = {\n      visit: (node, note) => {\n        if (node.type === 'heading') {\n          note.properties.hasHeading = true;\n        }\n      },\n    };\n    const parser = createMarkdownParser([testPlugin]);\n\n    it('can augment the parsing of the file', () => {\n      const note1 = parser.parse(\n        URI.file('/path/to/a'),\n        `\nThis is a test note without headings.\nBut with some content.\n`\n      );\n      expect(note1.properties.hasHeading).toBeUndefined();\n\n      const note2 = parser.parse(\n        URI.file('/path/to/a'),\n        `\n# This is a note with header\nand some content`\n      );\n      expect(note2.properties.hasHeading).toBeTruthy();\n    });\n  });\n  describe('Alias', () => {\n    it('can find tags in comma separated string', () => {\n      const note = parser.parse(\n        URI.file('/path/to/a'),\n        `\n---\nalias: alias 1, alias 2   , alias3 \n---\nThis is a test note without headings.\nBut with some content.\n`\n      );\n      expect(note.aliases).toEqual([\n        {\n          range: Range.create(1, 0, 3, 3),\n          title: 'alias 1',\n        },\n        {\n          range: Range.create(1, 0, 3, 3),\n          title: 'alias 2',\n        },\n        {\n          range: Range.create(1, 0, 3, 3),\n          title: 'alias3',\n        },\n      ]);\n    });\n  });\n  it('can find tags in yaml array', () => {\n    const note = parser.parse(\n      URI.file('/path/to/a'),\n      `\n---\nalias:\n- alias 1\n- alias 2\n- alias3\n---\nThis is a test note without headings.\nBut with some content.\n`\n    );\n    expect(note.aliases).toEqual([\n      {\n        range: Range.create(1, 0, 6, 3),\n        title: 'alias 1',\n      },\n      {\n        range: Range.create(1, 0, 6, 3),\n        title: 'alias 2',\n      },\n      {\n        range: Range.create(1, 0, 6, 3),\n        title: 'alias3',\n      },\n    ]);\n  });\n});\n\ndescribe('Block detection for lists', () => {\n  const md = `\n- this is block 1\n- this is [[block]] 2\n  - this is block 2.1\n- this is block 3\n  - this is block 3.1\n    - this is block 3.1.1\n  - this is block 3.2\n- this is block 4\nthis is a simple line\nthis is another simple line\n  `;\n\n  it('can detect block', () => {\n    const { block } = getBlockFor(md, 1);\n    expect(block).toEqual('- this is block 1');\n  });\n\n  it('supports nested blocks 1', () => {\n    const { block } = getBlockFor(md, 2);\n    expect(block).toEqual(`- this is [[block]] 2\n  - this is block 2.1`);\n  });\n\n  it('supports nested blocks 2', () => {\n    const { block } = getBlockFor(md, 5);\n    expect(block).toEqual(`  - this is block 3.1\n    - this is block 3.1.1`);\n  });\n\n  it('returns the line if no block is detected', () => {\n    const { block } = getBlockFor(md, 9);\n    expect(block).toEqual(`this is a simple line`);\n  });\n\n  it('is compatible with Range object', () => {\n    const note = parser.parse(URI.file('/path/to/a'), md);\n    const { start } = note.links[0].range;\n    const { block } = getBlockFor(md, start);\n    expect(block).toEqual(`- this is [[block]] 2\n  - this is block 2.1`);\n  });\n});\n\ndescribe('block detection for sections', () => {\n  const markdown = `\n# Section 1\n- this is block 1\n- this is [[block]] 2\n  - this is block 2.1\n\n# Section 2\nthis is a simple line\nthis is another simple line\n\n## Section 2.1\n  - this is block 3.1\n    - this is block 3.1.1\n  - this is block 3.2\n\n# Section 3\n# Section 4\nsome text\nsome text\n`;\n\n  it('should return correct block for valid markdown string with line number', () => {\n    const { block, nLines } = getBlockFor(markdown, 1);\n    expect(block).toEqual(`# Section 1\n- this is block 1\n- this is [[block]] 2\n  - this is block 2.1\n`);\n    expect(nLines).toEqual(5);\n  });\n\n  it('should return correct block for valid markdown string with position', () => {\n    const { block, nLines } = getBlockFor(markdown, 6);\n    expect(block).toEqual(`# Section 2\nthis is a simple line\nthis is another simple line\n\n## Section 2.1\n  - this is block 3.1\n    - this is block 3.1.1\n  - this is block 3.2\n`);\n    expect(nLines).toEqual(9);\n  });\n\n  it('should return single line for section with no content', () => {\n    const { block, nLines } = getBlockFor(markdown, 15);\n    expect(block).toEqual('# Section 3');\n    expect(nLines).toEqual(1);\n  });\n\n  it('should return till end of file for last section', () => {\n    const { block, nLines } = getBlockFor(markdown, 16);\n    expect(block).toEqual(`# Section 4\nsome text\nsome text`);\n    expect(nLines).toEqual(3);\n  });\n\n  it('should return single line for non-existing line number', () => {\n    const { block, nLines } = getBlockFor(markdown, 100);\n    expect(block).toEqual('');\n    expect(nLines).toEqual(1);\n  });\n\n  it('should return single line for non-existing position', () => {\n    const { block, nLines } = getBlockFor(markdown, Position.create(100, 2));\n    expect(block).toEqual('');\n    expect(nLines).toEqual(1);\n  });\n});\n\ndescribe('block anchor extraction', () => {\n  it('should extract block anchor from a paragraph', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `This is a paragraph ^myblock`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('myblock');\n    expect(note.blocks[0].type).toBe('paragraph');\n    expect(note.blocks[0].range.start.line).toBe(0);\n    expect(note.blocks[0].range.end.line).toBe(0);\n  });\n\n  it('should extract block anchor from a multi-line paragraph', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `Line one\\nLine two ^multiblock`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('multiblock');\n    expect(note.blocks[0].type).toBe('paragraph');\n    expect(note.blocks[0].range.start.line).toBe(0);\n    expect(note.blocks[0].range.end.line).toBe(1);\n  });\n\n  it('should support hyphens in block IDs', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `A paragraph ^my-block-id`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('my-block-id');\n  });\n\n  it('should extract block anchor from a list item, with range covering sub-items', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `- Parent item ^myblock\\n  - Child 1\\n  - Child 2\\n- Another item`\n    );\n    const block = note.blocks.find(b => b.id === 'myblock');\n    expect(block).toBeDefined();\n    expect(block.type).toBe('list-item');\n    // Range must cover all sub-items (lines 0-2, not just line 0)\n    expect(block.range.start.line).toBe(0);\n    expect(block.range.end.line).toBeGreaterThan(0);\n  });\n\n  it('should extract block anchor from a nested sub-item with range limited to its subtree', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `- Parent item\\n  - Child ^childblock\\n    - Grandchild\\n- Another item`\n    );\n    const block = note.blocks.find(b => b.id === 'childblock');\n    expect(block).toBeDefined();\n    expect(block.type).toBe('list-item');\n    expect(block.range.start.line).toBe(1);\n    expect(block.range.end.line).toBeGreaterThan(1); // includes grandchild\n    expect(block.range.end.line).toBeLessThan(3); // excludes \"Another item\"\n  });\n\n  it('should extract block anchor from a heading', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `## My Heading ^headingblock\\n\\nSome content`\n    );\n    const block = note.blocks.find(b => b.id === 'headingblock');\n    expect(block).toBeDefined();\n    expect(block.type).toBe('heading');\n    // heading block range is just the heading line, not the section content\n    expect(block.range.start.line).toBe(0);\n    expect(block.range.end.line).toBe(0);\n  });\n\n  it('should strip ^id from section label when heading has a block anchor', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `## My Heading ^headingblock\\n\\nSome content`\n    );\n    expect(note.sections).toHaveLength(1);\n    expect(note.sections[0].label).toBe('My Heading');\n  });\n\n  it('should extract block anchor from a blockquote', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `> This is a quote ^quoteblock`\n    );\n    const block = note.blocks.find(b => b.id === 'quoteblock');\n    expect(block).toBeDefined();\n    expect(block.type).toBe('blockquote');\n    expect(block.range.start.line).toBe(0);\n    expect(block.range.end.line).toBe(0);\n  });\n\n  it('should extract block anchor from a multi-line blockquote', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `> Line one\\n> Line two ^quotemulti`\n    );\n    const block = note.blocks.find(b => b.id === 'quotemulti');\n    expect(block).toBeDefined();\n    expect(block.type).toBe('blockquote');\n    expect(block.range.start.line).toBe(0);\n    expect(block.range.end.line).toBe(1);\n  });\n\n  it('should extract multiple block anchors from a file', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `First paragraph ^first\\n\\nSecond paragraph ^second`\n    );\n    expect(note.blocks).toHaveLength(2);\n    expect(note.blocks.map(b => b.id)).toEqual(['first', 'second']);\n  });\n\n  it('should not extract blocks from elements without ^id', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `Just a paragraph\\n\\n- A list item`\n    );\n    expect(note.blocks).toHaveLength(0);\n  });\n\n  it('should keep all occurrences when duplicate block IDs are present', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `First paragraph ^dup\\n\\nSecond paragraph ^dup`\n    );\n    expect(note.blocks).toHaveLength(2);\n    expect(note.blocks[0].range.start.line).toBe(0);\n    expect(note.blocks[1].range.start.line).toBe(2);\n  });\n\n  it('should use first-wins for duplicate block IDs when resolving', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `First paragraph ^dup\\n\\nSecond paragraph ^dup`\n    );\n    const found = Resource.findBlock(note, 'dup');\n    expect(found).not.toBeNull();\n    expect(found.range.start.line).toBe(0);\n  });\n\n  it('should register a list item block anchor only once (not once per node type)', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `- Item one ^listblock\\n- Item two\\n`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('listblock');\n  });\n\n  it('should register a list item anchor only once even when it has nested subitems', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `- this is item ^listblock\\n  - subitem\\n`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('listblock');\n  });\n\n  it('should register a blockquote block anchor only once (not once per node type)', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `> Quote text ^quoteblock\\n`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('quoteblock');\n    expect(note.blocks[0].type).toBe('blockquote');\n  });\n\n  it('should not extract footnote references as block anchors', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `A paragraph with a footnote[^1]\\n\\n[^1]: The footnote text`\n    );\n    expect(note.blocks).toHaveLength(0);\n  });\n\n  it('should not extract ^id from the middle of a paragraph', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `Text ^notanid more text after`\n    );\n    expect(note.blocks).toHaveLength(0);\n  });\n\n  it('should only accept valid block ID characters [a-zA-Z0-9-]', () => {\n    const note = parser.parse(\n      URI.file('/path/note.md'),\n      `Valid ^valid-id-123\\n\\nInvalid ^invalid_id`\n    );\n    expect(note.blocks).toHaveLength(1);\n    expect(note.blocks[0].id).toBe('valid-id-123');\n  });\n\n  describe('full-line block IDs (Obsidian-compatible)', () => {\n    it('should extract a full-line block ID after a code fence', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '```js\\nconsole.log(\"hi\");\\n```\\n^mycode'\n      );\n      const block = note.blocks.find(b => b.id === 'mycode');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('code');\n      expect(block.range.start.line).toBe(0);\n      expect(block.range.end.line).toBe(2);\n    });\n\n    it('should extract a full-line block ID after a table', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '| A | B |\\n| - | - |\\n| 1 | 2 |\\n^mytable'\n      );\n      const block = note.blocks.find(b => b.id === 'mytable');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('table');\n      expect(block.range.start.line).toBe(0);\n      expect(block.range.end.line).toBe(2);\n    });\n\n    it('should extract a full-line block ID after a list (full list anchoring)', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '- Item one\\n- Item two\\n- Item three\\n^mylist'\n      );\n      const block = note.blocks.find(b => b.id === 'mylist');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('list');\n      expect(block.range.start.line).toBe(0);\n      // Range should not include the ^id line\n      expect(block.range.end.line).toBe(2);\n    });\n\n    it('should match a full-line block ID after code fence separated by one blank line', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '```js\\ncode();\\n```\\n\\n^mycode'\n      );\n      const block = note.blocks.find(b => b.id === 'mycode');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('code');\n    });\n\n    it('should match a full-line block ID after table separated by one blank line', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '| A | B |\\n| - | - |\\n| 1 | 2 |\\n\\n^mytable'\n      );\n      const block = note.blocks.find(b => b.id === 'mytable');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('table');\n    });\n\n    it('should not match a full-line block ID after code fence separated by two blank lines', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '```js\\ncode();\\n```\\n\\n\\n^mycode'\n      );\n      expect(note.blocks.find(b => b.id === 'mycode')).toBeUndefined();\n    });\n\n    it('should not match a full-line block ID after table separated by two blank lines', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '| A | B |\\n| - | - |\\n| 1 | 2 |\\n\\n\\n^mytable'\n      );\n      expect(note.blocks.find(b => b.id === 'mytable')).toBeUndefined();\n    });\n\n    it('should extract a full-line block ID right after a blockquote (lazy continuation, no blank line)', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '> First line\\n> Second line\\n^myquote'\n      );\n      const block = note.blocks.find(b => b.id === 'myquote');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('blockquote');\n      // Range should not include the ^id line\n      expect(block.range.start.line).toBe(0);\n      expect(block.range.end.line).toBe(1);\n      // markerRange: line 2 (0-indexed), column 0 (^id on its own line)\n      expect(block.markerRange.start.line).toBe(2);\n      expect(block.markerRange.start.character).toBe(0);\n      expect(block.markerRange.end.character).toBe(1 + 'myquote'.length); // \"^myquote\"\n    });\n\n    it('should extract a full-line block ID after a blockquote separated by one blank line', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '> First line\\n> Second line\\n\\n^myquote'\n      );\n      const block = note.blocks.find(b => b.id === 'myquote');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('blockquote');\n      expect(block.range.start.line).toBe(0);\n      expect(block.range.end.line).toBe(1);\n    });\n\n    it('should extract a full-line block ID as the last line inside a blockquote', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '> First line\\n> Second line\\n> ^myquote'\n      );\n      const block = note.blocks.find(b => b.id === 'myquote');\n      expect(block).toBeDefined();\n      expect(block.type).toBe('blockquote');\n      // Range should not include the ^id line\n      expect(block.range.start.line).toBe(0);\n      expect(block.range.end.line).toBe(1);\n      // markerRange: line 2 (0-indexed), columns 2-9 (after \"> \")\n      expect(block.markerRange.start.line).toBe(2);\n      expect(block.markerRange.start.character).toBe(2); // after \"> \"\n      expect(block.markerRange.end.line).toBe(2);\n      expect(block.markerRange.end.character).toBe(2 + 1 + 'myquote'.length); // \"^myquote\"\n    });\n\n    it('should handle multiple full-line block IDs in the same document', () => {\n      const note = parser.parse(\n        URI.file('/path/note.md'),\n        '```\\ncode\\n```\\n^block1\\n\\n| A |\\n| - |\\n^block2'\n      );\n      const b1 = note.blocks.find(b => b.id === 'block1');\n      const b2 = note.blocks.find(b => b.id === 'block2');\n      expect(b1).toBeDefined();\n      expect(b1.type).toBe('code');\n      expect(b2).toBeDefined();\n      expect(b2.type).toBe('table');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-parser.ts",
    "content": "// eslint-disable-next-line import/no-extraneous-dependencies\nimport { Point, Node, Position as AstPosition } from 'unist';\nimport unified from 'unified';\nimport markdownParse from 'remark-parse';\nimport wikiLinkPlugin from 'remark-wiki-link';\nimport frontmatterPlugin from 'remark-frontmatter';\nimport { parse as parseYAML } from 'yaml';\nimport visit from 'unist-util-visit';\nimport {\n  BlockType,\n  NoteLinkDefinition,\n  Resource,\n  ResourceLink,\n  ResourceParser,\n} from '../model/note';\nimport { Position } from '../model/position';\nimport { Range } from '../model/range';\nimport { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport { ICache } from '../utils/cache';\n\nexport interface ParserPlugin {\n  name?: string;\n  visit?: (node: Node, note: Resource, noteSource: string) => void;\n  onDidInitializeParser?: (parser: unified.Processor) => void;\n  onWillParseMarkdown?: (markdown: string) => string;\n  onWillVisitTree?: (tree: Node, note: Resource) => void;\n  onDidVisitTree?: (tree: Node, note: Resource) => void;\n  onDidFindProperties?: (properties: any, note: Resource, node: Node) => void;\n}\n\ntype Checksum = string;\n\nexport interface ParserCacheEntry {\n  checksum: Checksum;\n  resource: Resource;\n}\n\n/**\n * This caches the parsed markdown for a given URI.\n *\n * The URI identifies the resource that needs to be parsed,\n * the checksum identifies the text that needs to be parsed.\n *\n * If the URI and the Checksum have not changed, the cached resource is returned.\n */\nexport type ParserCache = ICache<URI, ParserCacheEntry>;\n\nconst parser = unified()\n  .use(markdownParse, { gfm: true })\n  .use(frontmatterPlugin, ['yaml'])\n  .use(wikiLinkPlugin, { aliasDivider: '|' });\n\nexport function getLinkDefinitions(markdown: string): NoteLinkDefinition[] {\n  const definitions: NoteLinkDefinition[] = [];\n  const tree = parser.parse(markdown);\n  visit(tree, node => {\n    if (node.type === 'definition') {\n      definitions.push({\n        label: (node as any).label,\n        url: (node as any).url,\n        title: (node as any).title,\n        range: astPositionToFoamRange(node.position!),\n      });\n    }\n  });\n  return definitions;\n}\n\nexport function createMarkdownParser(\n  extraPlugins: ParserPlugin[] = [],\n  cache?: ParserCache\n): ResourceParser {\n  const plugins = [\n    titlePlugin,\n    wikilinkPlugin,\n    tagsPlugin,\n    aliasesPlugin,\n    sectionsPlugin,\n    blocksPlugin,\n    ...extraPlugins,\n  ];\n\n  for (const plugin of plugins) {\n    try {\n      plugin.onDidInitializeParser?.(parser);\n    } catch (e) {\n      handleError(plugin, 'onDidInitializeParser', undefined, e);\n    }\n  }\n\n  const foamParser: ResourceParser = {\n    parse: (uri: URI, markdown: string): Resource => {\n      Logger.debug('Parsing:', uri.toString());\n      for (const plugin of plugins) {\n        try {\n          plugin.onWillParseMarkdown?.(markdown);\n        } catch (e) {\n          handleError(plugin, 'onWillParseMarkdown', uri, e);\n        }\n      }\n      const tree = parser.parse(markdown);\n\n      const note: Resource = {\n        uri: uri,\n        type: 'note',\n        properties: {},\n        title: '',\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [],\n        links: [],\n      };\n\n      const localDefinitions: NoteLinkDefinition[] = [];\n\n      for (const plugin of plugins) {\n        try {\n          plugin.onWillVisitTree?.(tree, note);\n        } catch (e) {\n          handleError(plugin, 'onWillVisitTree', uri, e);\n        }\n      }\n      visit(tree, node => {\n        if (node.type === 'yaml') {\n          try {\n            const yamlProperties = parseYAML((node as any).value) ?? {};\n            note.properties = {\n              ...note.properties,\n              ...yamlProperties,\n            };\n            for (const plugin of plugins) {\n              try {\n                plugin.onDidFindProperties?.(yamlProperties, note, node);\n              } catch (e) {\n                handleError(plugin, 'onDidFindProperties', uri, e);\n              }\n            }\n          } catch (e) {\n            Logger.warn(`Error while parsing YAML for [${uri.toString()}]`, e);\n          }\n        }\n\n        if (node.type === 'definition') {\n          localDefinitions.push({\n            label: (node as any).label,\n            url: (node as any).url,\n            title: (node as any).title,\n            range: astPositionToFoamRange(node.position!),\n          });\n        }\n\n        for (const plugin of plugins) {\n          try {\n            plugin.visit?.(node, note, markdown);\n          } catch (e) {\n            handleError(plugin, 'visit', uri, e);\n          }\n        }\n      });\n      for (const plugin of plugins) {\n        try {\n          plugin.onDidVisitTree?.(tree, note);\n        } catch (e) {\n          handleError(plugin, 'onDidVisitTree', uri, e);\n        }\n      }\n\n      // Post-processing: Resolve reference identifiers to definitions for all links\n      note.links.forEach(link => {\n        if (ResourceLink.isUnresolvedReference(link)) {\n          // This link has a reference identifier (from linkReference or wikilink)\n          const referenceId = link.definition;\n          const definition = localDefinitions.find(\n            def => def.label === referenceId\n          );\n\n          // Set definition to definition object if found, otherwise keep as string\n          (link as any).definition = definition || referenceId;\n        }\n      });\n\n      // For type: 'link', keep only if:\n      // - It's a direct link [text](url) - no definition field\n      // - It's a resolved reference - definition is an object\n      note.links = note.links.filter(\n        link =>\n          link.type === 'wikilink' || !ResourceLink.isUnresolvedReference(link)\n      );\n\n      Logger.debug('Result:', note);\n      return note;\n    },\n  };\n\n  const cachedParser: ResourceParser = {\n    parse: (uri: URI, markdown: string): Resource => {\n      const actualChecksum = hash(markdown);\n      if (cache.has(uri)) {\n        const { checksum, resource } = cache.get(uri);\n        if (actualChecksum === checksum) {\n          return resource;\n        }\n      }\n      const resource = foamParser.parse(uri, markdown);\n      cache.set(uri, { checksum: actualChecksum, resource });\n      return resource;\n    },\n  };\n\n  return isSome(cache) ? cachedParser : foamParser;\n}\n\n/**\n * Traverses all the children of the given node, extracts\n * the text from them, and returns it concatenated.\n *\n * @param root the node from which to start collecting text\n */\nconst getTextFromChildren = (root: Node): string => {\n  let text = '';\n  visit(root, node => {\n    if (node.type === 'text' || node.type === 'wikiLink') {\n      text = text + ((node as any).value || '');\n    }\n  });\n  return text;\n};\n\nfunction getPropertiesInfoFromYAML(yamlText: string): {\n  [key: string]: { key: string; value: string; text: string; line: number };\n} {\n  const yamlProps = `\\n${yamlText}`\n    .split(/[\\n](\\w+:)/g)\n    .filter(item => item.trim() !== '');\n  const lines = yamlText.split('\\n');\n  let result: { line: number; key: string; text: string; value: string }[] = [];\n  for (let i = 0; i < yamlProps.length / 2; i++) {\n    const key = yamlProps[i * 2].replace(':', '');\n    const value = yamlProps[i * 2 + 1].trim();\n    const text = yamlProps[i * 2] + yamlProps[i * 2 + 1];\n    result.push({ key, value, text, line: -1 });\n  }\n  result = result.map(p => {\n    const line = lines.findIndex(l => l.startsWith(p.key + ':'));\n    return { ...p, line };\n  });\n  return result.reduce((acc, curr) => {\n    acc[curr.key] = curr;\n    return acc;\n  }, {});\n}\n\nconst tagsPlugin: ParserPlugin = {\n  name: 'tags',\n  onDidFindProperties: (props, note, node) => {\n    if (isSome(props.tags)) {\n      const tagPropertyInfo = getPropertiesInfoFromYAML((node as any).value)[\n        'tags'\n      ];\n      const tagPropertyStartLine =\n        node.position!.start.line + tagPropertyInfo.line;\n      const tagPropertyLines = tagPropertyInfo.text.split('\\n');\n      const yamlTags = extractTagsFromProp(props.tags);\n      for (const tag of yamlTags) {\n        const tagLine = tagPropertyLines.findIndex(l => l.includes(tag));\n        const line = tagPropertyStartLine + tagLine;\n        const charStart = tagPropertyLines[tagLine].indexOf(tag);\n        note.tags.push({\n          label: tag,\n          range: Range.createFromPosition(\n            Position.create(line, charStart),\n            Position.create(line, charStart + tag.length)\n          ),\n        });\n      }\n    }\n  },\n  visit: (node, note) => {\n    if (node.type === 'text') {\n      const tags = extractHashtags((node as any).value);\n      for (const tag of tags) {\n        const start = astPointToFoamPosition(node.position!.start);\n        start.character = start.character + tag.offset;\n        const end: Position = {\n          line: start.line,\n          character: start.character + tag.label.length + 1,\n        };\n        note.tags.push({\n          label: tag.label,\n          range: Range.createFromPosition(start, end),\n        });\n      }\n    }\n  },\n};\n\nlet sectionStack: Array<{ label: string; level: number; start: Position }> = [];\nconst sectionsPlugin: ParserPlugin = {\n  name: 'section',\n  onWillVisitTree: () => {\n    sectionStack = [];\n  },\n  visit: (node, note) => {\n    if (node.type === 'heading') {\n      const level = (node as any).depth;\n      const rawLabel = getTextFromChildren(node);\n      // Strip trailing block anchor (e.g. \"My Heading ^blockid\" → \"My Heading\")\n      const label = rawLabel.replace(/\\s\\^[a-zA-Z0-9-]+$/, '');\n      if (!label || !level) {\n        return;\n      }\n      const start = astPositionToFoamRange(node.position!).start;\n\n      // Close all the sections that are not parents of the current section\n      while (\n        sectionStack.length > 0 &&\n        sectionStack[sectionStack.length - 1].level >= level\n      ) {\n        const section = sectionStack.pop();\n        note.sections.push({\n          label: section.label,\n          range: Range.createFromPosition(section.start, start),\n        });\n      }\n\n      // Add the new section to the stack\n      sectionStack.push({ label, level, start });\n    }\n  },\n  onDidVisitTree: (tree, note) => {\n    const end = Position.create(\n      astPointToFoamPosition(tree.position.end).line + 1,\n      0\n    );\n    // Close all the remaining sections\n    while (sectionStack.length > 0) {\n      const section = sectionStack.pop();\n      note.sections.push({\n        label: section.label,\n        range: { start: section.start, end },\n      });\n    }\n    note.sections.sort((a, b) =>\n      Position.compareTo(a.range.start, b.range.start)\n    );\n  },\n};\n\n// Captures the whitespace character before the anchor so we can distinguish\n// same-line ( ) from own-line (\\n) markers.\nconst BLOCK_ANCHOR_REGEX = /(\\s)\\^([a-zA-Z0-9-]+)$/;\n// Matches a paragraph that contains *only* a block anchor (full-line syntax).\nconst STANDALONE_BLOCK_ANCHOR_RE = /^\\^([a-zA-Z0-9-]+)$/;\n\n/**\n * Returns the direct text content of a node, without descending into\n * child list items or other nested block elements. For list items,\n * only the text of the first paragraph child is considered so that\n * anchors on sub-items are not attributed to the parent.\n */\nconst getDirectText = (node: Node): string => {\n  if (node.type === 'listItem') {\n    const firstPara = (node as any).children?.find(\n      (c: any) => c.type === 'paragraph'\n    );\n    return firstPara ? getTextFromChildren(firstPara) : '';\n  }\n  return getTextFromChildren(node);\n};\n\nconst BLOCK_NODE_TYPES: Record<string, BlockType> = {\n  paragraph: 'paragraph',\n  listItem: 'list-item',\n  blockquote: 'blockquote',\n  heading: 'heading',\n};\n\n// Block types where the ^id appears on its own line *after* the block (as a\n// sibling paragraph in the remark AST).\nconst FULL_LINE_SIBLING_TYPES: Record<string, BlockType> = {\n  code: 'code',\n  table: 'table',\n  blockquote: 'blockquote',\n};\n\nconst blocksPlugin: ParserPlugin = {\n  name: 'blocks',\n  visit: (node, note, noteSource) => {\n    const blockType = BLOCK_NODE_TYPES[node.type];\n    if (!blockType) {\n      return;\n    }\n    const text = getDirectText(node);\n    const match = BLOCK_ANCHOR_REGEX.exec(text);\n    if (!match) {\n      return;\n    }\n    const [, whitespace, id] = match;\n\n    // Full-line block ID on a list item: the ^id is on its own line and remark\n    // absorbs it via lazy continuation into the last listItem's paragraph.\n    // Skip both the listItem and the paragraph — onDidVisitTree registers the list.\n    if (\n      whitespace === '\\n' &&\n      (blockType === 'list-item' || blockType === 'paragraph')\n    ) {\n      return;\n    }\n\n    const startLine = node.position!.start.line - 1; // convert AST 1-based to 0-based\n    // A listItem and its first-paragraph child both start on the same line and\n    // carry the same anchor. Skip the paragraph once the listItem is registered.\n    // Using start line (not end line) handles nested subitems that extend the\n    // parent listItem's end line beyond the anchor line.\n    if (\n      note.blocks.some(b => b.id === id && b.range.start.line === startLine)\n    ) {\n      return;\n    }\n\n    // Full-line block ID on a blockquote: remark absorbs the ^id via lazy\n    // continuation, so the blockquote's end line includes the ^id line. Adjust\n    // the range to exclude that line so embeds don't include the raw marker.\n    const pos =\n      whitespace === '\\n' && blockType === 'blockquote'\n        ? {\n            ...node.position!,\n            end: {\n              ...node.position!.end,\n              line: node.position!.end.line - 1,\n            },\n          }\n        : node.position!;\n\n    // The marker end position: for list-items the ^id is on the first\n    // paragraph's line, not necessarily the listItem's last line.\n    const markerEndPos =\n      blockType === 'list-item'\n        ? (node as any).children?.find((c: any) => c.type === 'paragraph')\n            ?.position?.end ?? node.position!.end\n        : node.position!.end;\n\n    const markerRange =\n      whitespace === '\\n' && blockType === 'blockquote'\n        ? // Own-line marker: the ^id is on the last line, possibly prefixed by \"> \".\n          // Find the actual column by scanning the source line.\n          (() => {\n            const markerLine = node.position!.end.line - 1; // 0-indexed\n            const sourceLine = noteSource.split('\\n')[markerLine] ?? '';\n            const markerCol = sourceLine.indexOf(`^${id}`);\n            const col = markerCol >= 0 ? markerCol : 0;\n            return Range.create(\n              markerLine,\n              col,\n              markerLine,\n              col + id.length + 1\n            );\n          })()\n        : // Inline marker: '^id' at the end of the element's last line (space excluded).\n          Range.create(\n            markerEndPos.line - 1,\n            markerEndPos.column - 1 - (id.length + 1), // '^' + id\n            markerEndPos.line - 1,\n            markerEndPos.column - 1\n          );\n\n    note.blocks.push({\n      id,\n      type: blockType,\n      range: astPositionToFoamRange(pos),\n      markerRange,\n    });\n  },\n\n  onDidVisitTree: (tree, note) => {\n    // Handle full-line block IDs for block types where the ^id appears as a\n    // standalone sibling paragraph immediately after the target block (code,\n    // table) or is absorbed into the last list item (list).\n    visit(tree, (parentNode: any) => {\n      if (!Array.isArray(parentNode.children)) {\n        return;\n      }\n      const children: any[] = parentNode.children;\n\n      for (let i = 0; i < children.length; i++) {\n        const current = children[i];\n\n        // Case A: code/table — remark places the ^id as the next sibling paragraph.\n        // Allow up to one blank line between the block and the ^id paragraph so\n        // markdown formatters that insert blank lines don't break the syntax.\n        if (FULL_LINE_SIBLING_TYPES[current.type]) {\n          const next = children[i + 1];\n          if (\n            next?.type === 'paragraph' &&\n            next.position.start.line <= current.position.end.line + 2\n          ) {\n            const nextText = getTextFromChildren(next).trim();\n            const idMatch = STANDALONE_BLOCK_ANCHOR_RE.exec(nextText);\n            if (idMatch) {\n              const id = idMatch[1];\n              if (!note.blocks.some(b => b.id === id)) {\n                note.blocks.push({\n                  id,\n                  type: FULL_LINE_SIBLING_TYPES[current.type],\n                  range: astPositionToFoamRange(current.position),\n                  // Marker is the ^id paragraph on its own line.\n                  markerRange: Range.create(\n                    next.position.start.line - 1,\n                    0,\n                    next.position.start.line - 1,\n                    id.length + 1\n                  ),\n                });\n              }\n            }\n          }\n        }\n\n        // Case B: list — remark absorbs the ^id into the last listItem's text\n        // as a new line. Register the ID for the *full list* instead.\n        if (current.type === 'list') {\n          const items: any[] = current.children ?? [];\n          const lastItem = items[items.length - 1];\n          if (!lastItem) continue;\n          const text = getDirectText(lastItem);\n          const match = /\\n\\^([a-zA-Z0-9-]+)$/.exec(text);\n          if (!match) continue;\n          const id = match[1];\n          if (note.blocks.some(b => b.id === id)) continue;\n          // Range covers the whole list minus the ^id line.\n          const adjustedPos = {\n            ...current.position,\n            end: {\n              ...current.position.end,\n              line: current.position.end.line - 1,\n            },\n          };\n          note.blocks.push({\n            id,\n            type: 'list',\n            range: astPositionToFoamRange(adjustedPos),\n            // Marker is ^id on the original last line of the list.\n            markerRange: Range.create(\n              current.position.end.line - 1,\n              0,\n              current.position.end.line - 1,\n              id.length + 1\n            ),\n          });\n        }\n      }\n    });\n  },\n};\n\nconst titlePlugin: ParserPlugin = {\n  name: 'title',\n  visit: (node, note) => {\n    if (\n      note.title === '' &&\n      node.type === 'heading' &&\n      (node as any).depth === 1\n    ) {\n      const title = getTextFromChildren(node);\n      note.title = title.length > 0 ? title : note.title;\n    }\n  },\n  onDidFindProperties: (props, note) => {\n    // Give precedence to the title from the frontmatter if it exists\n    note.title = props.title?.toString() ?? note.title;\n  },\n  onDidVisitTree: (tree, note) => {\n    if (note.title === '') {\n      note.title = note.uri.getName();\n    }\n  },\n};\n\nconst aliasesPlugin: ParserPlugin = {\n  name: 'aliases',\n  onDidFindProperties: (props, note, node) => {\n    if (isSome(props.alias)) {\n      const aliases = Array.isArray(props.alias)\n        ? props.alias\n        : props.alias.split(',').map(m => m.trim());\n      for (const alias of aliases) {\n        note.aliases.push({\n          title: alias,\n          range: astPositionToFoamRange(node.position!),\n        });\n      }\n    }\n  },\n};\n\nconst wikilinkPlugin: ParserPlugin = {\n  name: 'wikilink',\n  visit: (node, note, noteSource) => {\n    if (node.type === 'wikiLink') {\n      const isEmbed =\n        noteSource.charAt(node.position!.start.offset - 1) === '!';\n\n      const literalContent = noteSource.substring(\n        isEmbed\n          ? node.position!.start.offset! - 1\n          : node.position!.start.offset!,\n        node.position!.end.offset!\n      );\n\n      const range = isEmbed\n        ? Range.create(\n            node.position.start.line - 1,\n            node.position.start.column - 2,\n            node.position.end.line - 1,\n            node.position.end.column - 1\n          )\n        : astPositionToFoamRange(node.position!);\n\n      note.links.push({\n        type: 'wikilink',\n        rawText: literalContent,\n        range,\n        isEmbed,\n        definition: (node as any).value,\n      });\n    }\n    if (node.type === 'link' || node.type === 'image') {\n      const targetUri = (node as any).url;\n      const uri = note.uri.resolve(targetUri);\n      if (uri.scheme !== 'file' || uri.path === note.uri.path) {\n        return;\n      }\n      const literalContent = noteSource.substring(\n        node.position!.start.offset!,\n        node.position!.end.offset!\n      );\n      note.links.push({\n        type: 'link',\n        rawText: literalContent,\n        range: astPositionToFoamRange(node.position!),\n        isEmbed: literalContent.startsWith('!'),\n      });\n    }\n    if (node.type === 'linkReference') {\n      const literalContent = noteSource.substring(\n        node.position!.start.offset!,\n        node.position!.end.offset!\n      );\n\n      const identifier = (node as any).identifier;\n\n      note.links.push({\n        type: 'link',\n        rawText: literalContent,\n        range: astPositionToFoamRange(node.position!),\n        isEmbed: false,\n        // Store reference identifier temporarily - will be resolved in onDidVisitTree\n        definition: identifier,\n      });\n    }\n  },\n  onDidVisitTree: (tree, note) => {\n    // This onDidVisitTree is now handled globally after all plugins have run\n    // and localDefinitions have been collected.\n  },\n};\n\nconst handleError = (\n  plugin: ParserPlugin,\n  fnName: string,\n  uri: URI | undefined,\n  e: Error\n): void => {\n  const name = plugin.name || '';\n  Logger.warn(\n    `Error while executing [${fnName}] in plugin [${name}]. ${\n      uri ? 'for file [' + uri.toString() : ']'\n    }.`,\n    e\n  );\n};\n\n/**\n * Converts the 1-index Point object into the VS Code 0-index Position object\n * @param point ast Point (1-indexed)\n * @returns Foam Position  (0-indexed)\n */\nconst astPointToFoamPosition = (point: Point): Position => {\n  return Position.create(point.line - 1, point.column - 1);\n};\n\n/**\n * Converts the 1-index Position object into the VS Code 0-index Range object\n * @param position an ast Position object (1-indexed)\n * @returns Foam Range  (0-indexed)\n */\nconst astPositionToFoamRange = (pos: AstPosition): Range =>\n  Range.create(\n    pos.start.line - 1,\n    pos.start.column - 1,\n    pos.end.line - 1,\n    pos.end.column - 1\n  );\n\nconst blockParser = unified().use(markdownParse, { gfm: true });\nexport const getBlockFor = (\n  markdown: string,\n  line: number | Position\n): { block: string; nLines: number } => {\n  const searchLine = typeof line === 'number' ? line : line.line;\n  const tree = blockParser.parse(markdown);\n  const lines = markdown.split('\\n');\n  let startLine = -1;\n  let endLine = -1;\n\n  // For list items, we also include the sub-lists\n  visit(tree, ['listItem'], (node: any) => {\n    if (node.position.start.line === searchLine + 1) {\n      startLine = node.position.start.line - 1;\n      endLine = node.position.end.line;\n      return visit.EXIT;\n    }\n  });\n\n  // For headings, we also include the sub-sections\n  let headingLevel = -1;\n  visit(tree, ['heading'], (node: any) => {\n    if (startLine > -1 && node.depth <= headingLevel) {\n      endLine = node.position.start.line - 1;\n      return visit.EXIT;\n    }\n    if (node.position.start.line === searchLine + 1) {\n      headingLevel = node.depth;\n      startLine = node.position.start.line - 1;\n      endLine = lines.length - 1; // in case it's the last section\n    }\n  });\n\n  let nLines = startLine === -1 ? 1 : endLine - startLine;\n  let block =\n    startLine === -1\n      ? lines[searchLine] ?? ''\n      : lines.slice(startLine, endLine).join('\\n');\n\n  return { block, nLines };\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-provider.test.ts",
    "content": "import { createMarkdownParser } from './markdown-parser';\nimport {\n  createMarkdownReferences,\n  MarkdownResourceProvider,\n} from './markdown-provider';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport {\n  createTestNote,\n  createTestWorkspace,\n  getRandomURI,\n  InMemoryDataStore,\n} from '../../test/test-utils';\n\nLogger.setLevel('error');\n\nconst parser = createMarkdownParser([]);\nconst createNoteFromMarkdown = (content: string, path?: string) =>\n  parser.parse(path ? URI.file(path) : getRandomURI(), content);\n\ndescribe('Link resolution', () => {\n  describe('Wikilinks', () => {\n    it('should resolve basename wikilinks with files in same directory', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');\n      const noteB = createNoteFromMarkdown('Content of page b', './page b.md');\n      workspace.set(noteA).set(noteB);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should resolve basename wikilinks with files in other directory', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown('Link to [[page b]]', './page-a.md');\n      const noteB = createNoteFromMarkdown('Page b', './folder/page b.md');\n      workspace.set(noteA).set(noteB);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should resolve wikilinks that represent an absolute path', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        'Link to [[/folder/page b]]',\n        '/page-a.md'\n      );\n      const noteB = createNoteFromMarkdown('Page b', '/folder/page b.md');\n      workspace.set(noteA).set(noteB);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should resolve wikilinks that represent a relative path', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        'Link to [[../two/page b]]',\n        '/path/one/page-a.md'\n      );\n      const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');\n      const noteB2 = createNoteFromMarkdown('Page b 2', '/path/two/page b.md');\n      workspace.set(noteA).set(noteB).set(noteB2);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);\n    });\n\n    it('should resolve ambiguous wikilinks', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown('Link to [[page b]]', '/page-a.md');\n      const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');\n      const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');\n      workspace.set(noteA).set(noteB).set(noteB2);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should resolve path wikilink even with other ambiguous notes', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [{ slug: './more/page-b' }, { slug: 'yet/page-b' }],\n      });\n      const noteB1 = createTestNote({ uri: '/path/to/another/page-b.md' });\n      const noteB2 = createTestNote({ uri: '/path/to/more/page-b.md' });\n      const noteB3 = createTestNote({ uri: '/path/to/yet/page-b.md' });\n\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB1).set(noteB2).set(noteB3);\n\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);\n      expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteB3.uri);\n    });\n\n    it('should resolve Foam wikilinks', () => {\n      const workspace = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        'Link to [[two/page b]] and [[one/page b]]',\n        '/page-a.md'\n      );\n      const noteB = createNoteFromMarkdown('Page b', '/path/one/page b.md');\n      const noteB2 = createNoteFromMarkdown('Page b2', '/path/two/page b.md');\n      workspace.set(noteA).set(noteB).set(noteB2);\n      expect(workspace.resolveLink(noteA, noteA.links[0])).toEqual(noteB2.uri);\n      expect(workspace.resolveLink(noteA, noteA.links[1])).toEqual(noteB.uri);\n    });\n\n    it('should use wikilink definitions when available to resolve target', () => {\n      const ws = createTestWorkspace();\n      const noteA = createTestNote({\n        uri: '/somewhere/from/page-a.md',\n        links: [{ slug: 'page-b', definitionUrl: '../to/page-b.md' }],\n      });\n      const noteB = createTestNote({\n        uri: '/somewhere/to/page-b.md',\n      });\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should include section fragment from a resolved wikilink definition when rawText has no section', () => {\n      const ws = createTestWorkspace();\n      const noteA = createTestNote({\n        uri: '/somewhere/from/page-a.md',\n        links: [{ slug: 'page-b', definitionUrl: '../to/page-b.md#mysection' }],\n      });\n      const noteB = createTestNote({ uri: '/somewhere/to/page-b.md' });\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(\n        noteB.uri.with({ fragment: 'mysection' })\n      );\n    });\n\n    it('should support case insensitive wikilink resolution', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [\n          // uppercased filename, lowercased slug\n          { slug: 'page-b' },\n          // lowercased filename, camelcased wikilink\n          { slug: 'Page-C' },\n          // lowercased filename, lowercased wikilink\n          { slug: 'page-d' },\n        ],\n      });\n      const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });\n      const noteC = createTestNote({ uri: '/path/another/page-c.md' });\n      const noteD = createTestNote({ uri: '/path/another/page-d.md' });\n      const ws = createTestWorkspace()\n        .set(noteA)\n        .set(noteB)\n        .set(noteC)\n        .set(noteD);\n\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n      expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteC.uri);\n      expect(ws.resolveLink(noteA, noteA.links[2])).toEqual(noteD.uri);\n    });\n\n    it('should resolve wikilink with section identifier', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [\n          // uppercased filename, lowercased slug\n          { slug: 'page-b#section' },\n        ],\n      });\n      const noteB = createTestNote({ uri: '/somewhere/PAGE-B.md' });\n      const ws = createTestWorkspace().set(noteA).set(noteB);\n\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(\n        noteB.uri.with({ fragment: 'section' })\n      );\n    });\n\n    it('should resolve section-only wikilinks', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [\n          // uppercased filename, lowercased slug\n          { slug: '#section' },\n        ],\n      });\n      const ws = createTestWorkspace().set(noteA);\n\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(\n        noteA.uri.with({ fragment: 'section' })\n      );\n    });\n\n    it('should resolve wikilinks with special characters', () => {\n      const ws = createTestWorkspace();\n      const noteA = createNoteFromMarkdown(\n        `Link to [[page: a]] and [[page %b%]] and [[page? c]] and [[[page] d]] and\n         [[page ^e^]] and [[page \\`f\\`]] and [[page {g}]] and [[page ~i]] and\n         [[page /j]]`\n      );\n      const noteB = createNoteFromMarkdown(\n        'Note containing :',\n        '/dir1/page: a.md'\n      );\n      const noteC = createNoteFromMarkdown(\n        'Note containing %',\n        '/dir1/page %b%.md'\n      );\n      const noteD = createNoteFromMarkdown(\n        'Note containing ?',\n        '/dir1/page? c.md'\n      );\n      const noteE = createNoteFromMarkdown(\n        'Note containing ]',\n        '/dir1/[page] d.md'\n      );\n      const noteF = createNoteFromMarkdown(\n        'Note containing ^',\n        '/dir1/page ^e^.md'\n      );\n      const noteG = createNoteFromMarkdown(\n        'Note containing `',\n        '/dir1/page `f`.md'\n      );\n      const noteH = createNoteFromMarkdown(\n        'Note containing { and }',\n        '/dir1/page {g}.md'\n      );\n      const noteI = createNoteFromMarkdown(\n        'Note containing ~',\n        '/dir1/page ~i.md'\n      );\n      ws.set(noteA)\n        .set(noteB)\n        .set(noteC)\n        .set(noteD)\n        .set(noteE)\n        .set(noteF)\n        .set(noteG)\n        .set(noteH)\n        .set(noteI);\n\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n      expect(ws.resolveLink(noteA, noteA.links[1])).toEqual(noteC.uri);\n      expect(ws.resolveLink(noteA, noteA.links[2])).toEqual(noteD.uri);\n      expect(ws.resolveLink(noteA, noteA.links[3])).toEqual(noteE.uri);\n      expect(ws.resolveLink(noteA, noteA.links[4])).toEqual(noteF.uri);\n      expect(ws.resolveLink(noteA, noteA.links[5])).toEqual(noteG.uri);\n      expect(ws.resolveLink(noteA, noteA.links[6])).toEqual(noteH.uri);\n      expect(ws.resolveLink(noteA, noteA.links[7])).toEqual(noteI.uri);\n    });\n  });\n\n  describe('Markdown direct links', () => {\n    it('should support absolute path 1', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [{ to: '/path/to/another/page-b.md' }],\n      });\n      const noteB = createTestNote({\n        uri: '/path/to/another/page-b.md',\n        links: [{ to: '../../to/page-a.md' }],\n      });\n\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should support relative path 1', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [{ to: './another/page-b.md' }],\n      });\n      const noteB = createTestNote({\n        uri: '/path/to/another/page-b.md',\n        links: [{ to: '../../to/page-a.md' }],\n      });\n\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should support relative path 2', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [{ to: 'more/page-b.md' }],\n      });\n      const noteB = createTestNote({\n        uri: '/path/to/more/page-b.md',\n      });\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should default to relative path', () => {\n      const noteA = createTestNote({\n        uri: '/path/to/page-a.md',\n        links: [{ to: 'page .md' }],\n      });\n      const noteB = createTestNote({\n        uri: '/path/to/page .md',\n      });\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB);\n      expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n    });\n\n    it('should support angle syntax #1039', () => {\n      const noteA = createNoteFromMarkdown(\n        'Content of note a',\n        '/path/to/note a.md'\n      );\n      const noteB = createNoteFromMarkdown(\n        'Link to [note](<./note a.md>)',\n        '/path/to/note b.md'\n      );\n      const noteC = createNoteFromMarkdown(\n        'Link to [note](./note%20a.md)',\n        '/path/to/note c.md'\n      );\n      const noteD = createNoteFromMarkdown(\n        'Link to [note](./note a.md)',\n        '/path/to/note d.md'\n      );\n\n      const ws = createTestWorkspace();\n      ws.set(noteA).set(noteB).set(noteC).set(noteD);\n\n      expect(ws.resolveLink(noteB, noteB.links[0])).toEqual(noteA.uri);\n      expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);\n      // noteD has malformed URL with unencoded space, which gets treated as\n      // shortcut reference [note] without definition, now correctly filtered out\n      expect(noteD.links.length).toEqual(0);\n    });\n\n    describe('Workspace-relative paths (root-path relative)', () => {\n      it('should resolve workspace-relative paths starting with /', () => {\n        const noteA = createTestNote({\n          uri: '/workspace/dir1/page-a.md',\n          links: [{ to: '/dir2/page-b.md' }],\n        });\n        const noteB = createTestNote({\n          uri: '/workspace/dir2/page-b.md',\n        });\n\n        const ws = createTestWorkspace([URI.file('/workspace')]);\n\n        ws.set(noteA).set(noteB);\n        expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n      });\n\n      it('should resolve workspace-relative paths with nested directories', () => {\n        const noteA = createTestNote({\n          uri: '/workspace/project/notes/page-a.md',\n          links: [{ to: '/project/assets/image.png' }],\n        });\n        const assetB = createTestNote({\n          uri: '/workspace/project/assets/image.png',\n        });\n\n        const ws = createTestWorkspace([URI.file('/workspace')]);\n\n        ws.set(noteA).set(assetB);\n        expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(assetB.uri);\n      });\n\n      it('should handle workspace-relative paths with fragments', () => {\n        const noteA = createTestNote({\n          uri: '/workspace/dir1/page-a.md',\n          links: [{ to: '/dir2/page-b.md#section' }],\n        });\n        const noteB = createTestNote({\n          uri: '/workspace/dir2/page-b.md',\n        });\n\n        const ws = createTestWorkspace([URI.file('/workspace')]);\n\n        ws.set(noteA).set(noteB);\n        const resolved = ws.resolveLink(noteA, noteA.links[0]);\n        expect(resolved).toEqual(noteB.uri.with({ fragment: 'section' }));\n      });\n\n      it('should fall back to placeholder for non-existent workspace-relative paths', () => {\n        const noteA = createTestNote({\n          uri: '/workspace/dir1/page-a.md',\n          links: [{ to: '/dir2/non-existent.md' }],\n        });\n\n        const ws = createTestWorkspace([URI.file('/workspace')]);\n\n        ws.set(noteA);\n        const resolved = ws.resolveLink(noteA, noteA.links[0]);\n        expect(resolved.isPlaceholder()).toBe(true);\n        expect(resolved.path).toEqual('/workspace/dir2/non-existent.md');\n      });\n\n      it('should work with multiple workspace roots', () => {\n        const noteA = createTestNote({\n          uri: '/workspace1/dir1/page-a.md',\n          links: [{ to: '/shared/page-b.md' }],\n        });\n        const noteB = createTestNote({\n          uri: '/workspace2/shared/page-b.md',\n        });\n\n        const ws = createTestWorkspace([\n          URI.file('/workspace1'),\n          URI.file('/workspace2'),\n        ]);\n\n        ws.set(noteA).set(noteB);\n        expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n      });\n\n      it('should prefer root[0] when the workspace-relative path resolves to files in multiple roots', () => {\n        const noteA = createTestNote({\n          uri: '/workspace1/shared/file.md',\n        });\n        const noteB = createTestNote({\n          uri: '/workspace2/shared/file.md',\n        });\n        const linker = createTestNote({\n          uri: '/workspace1/dir/linker.md',\n          links: [{ to: '/shared/file.md' }],\n        });\n\n        const ws = createTestWorkspace([\n          URI.file('/workspace1'),\n          URI.file('/workspace2'),\n        ]);\n        ws.set(noteA).set(noteB).set(linker);\n\n        expect(ws.resolveLink(linker, linker.links[0])).toEqual(noteA.uri);\n      });\n\n      it('should create placeholder under root[0] when path does not exist in any root', () => {\n        const linker = createTestNote({\n          uri: '/workspace1/dir/linker.md',\n          links: [{ to: '/non-existent/file.md' }],\n        });\n\n        const ws = createTestWorkspace([\n          URI.file('/workspace1'),\n          URI.file('/workspace2'),\n        ]);\n        ws.set(linker);\n\n        const resolved = ws.resolveLink(linker, linker.links[0]);\n        expect(resolved.isPlaceholder()).toBe(true);\n        expect(resolved.path).toEqual('/workspace1/non-existent/file.md');\n      });\n\n      it('should resolve absolute directory link to index file in non-root[0] workspace root', () => {\n        const linker = createTestNote({\n          uri: '/workspace1/dir/linker.md',\n          links: [{ to: '/subdir' }],\n        });\n        const index = createTestNote({ uri: '/workspace2/subdir/index.md' });\n\n        const ws = createTestWorkspace([\n          URI.file('/workspace1'),\n          URI.file('/workspace2'),\n        ]);\n        ws.set(linker).set(index);\n\n        expect(ws.resolveLink(linker, linker.links[0])).toEqual(index.uri);\n      });\n\n      it('should preserve existing absolute path behavior when no workspace roots provided', () => {\n        const noteA = createTestNote({\n          uri: '/path/to/page-a.md',\n          links: [{ to: '/path/to/another/page-b.md' }],\n        });\n        const noteB = createTestNote({\n          uri: '/path/to/another/page-b.md',\n        });\n\n        const ws = createTestWorkspace();\n        ws.set(noteA).set(noteB);\n        // Default provider without workspace roots should work as before\n        expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);\n      });\n    });\n  });\n});\n\ndescribe('Generation of markdown references', () => {\n  it('should generate correct reference URL when wikilink resolves to a directory index file', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown('Link to [[bar]]', '/root/page-a.md');\n    const index = createTestNote({\n      uri: '/root/bar/index.md',\n      title: 'Bar Index',\n    });\n    workspace.set(noteA).set(index);\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references).toHaveLength(1);\n    expect(references[0].url).toBe('bar/index');\n    expect(references[0].label).toBe('bar');\n  });\n\n  it('should generate links without file extension when includeExtension = false', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b]] and [[page-c]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))\n      .set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references.map(r => r.url)).toEqual(['page-b', 'page-c']);\n  });\n\n  it('should generate links with file extension when includeExtension = true', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b]] and [[page-c]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))\n      .set(createNoteFromMarkdown('Content of note C', '/dir1/page-c.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references.map(r => r.url)).toEqual(['page-b.md', 'page-c.md']);\n  });\n\n  it('should always add extensions for attachments, even when includeExtension = false', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b]] and [[image.png]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir1/page-b.md'))\n      .set(createNoteFromMarkdown('', '/dir1/image.png'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references.map(r => r.url)).toEqual(['page-b', 'image.png']);\n  });\n\n  it('should use relative paths', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b]] and [[page-c]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir2/page-b.md'))\n      .set(createNoteFromMarkdown('Content of note C', '/dir3/page-c.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references.map(r => decodeURIComponent(r.url))).toEqual([\n      '../dir2/page-b.md',\n      '../dir3/page-c.md',\n    ]);\n  });\n\n  it('should generate links for embedded notes that are formatted properly', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to ![[page-b]] and [[page-c]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir2/page-b.md'))\n      .set(createNoteFromMarkdown('Content of note C', '/dir3/page-c.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references.map(r => [decodeURIComponent(r.url), r.label])).toEqual([\n      ['../dir2/page-b.md', 'page-b'],\n      ['../dir3/page-c.md', 'page-c'],\n    ]);\n  });\n\n  it('should not generate links for placeholders', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to ![[page-b]] and [[page-c]] and [[does-not-exist]] and ![[does-not-exist-either]]',\n      '/dir1/page-a.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Content of note B', '/dir2/page-b.md'))\n      .set(createNoteFromMarkdown('Content of note C', '/dir3/page-c.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references.map(r => decodeURIComponent(r.url))).toEqual([\n      '../dir2/page-b.md',\n      '../dir3/page-c.md',\n    ]);\n  });\n\n  it('should encode special characters in links', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      `Link to [[page: a]] and [[page %b%]] and [[page? c]] and [[[page] d]] and\n       [[page ^e^]] and [[page \\`f\\`]] and [[page {g}]] and [[page ~i]] and\n       [[page /j]]`\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Note containing :', '/dir1/page: a.md'))\n      .set(createNoteFromMarkdown('Note containing %', '/dir1/page %b%.md'))\n      .set(createNoteFromMarkdown('Note containing ?', '/dir1/page? c.md'))\n      .set(createNoteFromMarkdown('Note containing ]', '/dir1/[page] d.md'))\n      .set(createNoteFromMarkdown('Note containing ^', '/dir1/page ^e^.md'))\n      .set(createNoteFromMarkdown('Note containing `', '/dir1/page `f`.md'))\n      .set(\n        createNoteFromMarkdown('Note containing { and }', '/dir1/page {g}.md')\n      )\n      .set(createNoteFromMarkdown('Note containing ~', '/dir1/page ~i.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references.map(r => decodeURIComponent(r.url))).toEqual([\n      '../dir1/page: a.md',\n      '../dir1/page %b%.md',\n      '../dir1/page? c.md',\n      '../dir1/[page] d.md',\n      '../dir1/page ^e^.md',\n      '../dir1/page `f`.md',\n      '../dir1/page {g}.md',\n      '../dir1/page ~i.md',\n    ]);\n  });\n\n  it('should preserve section fragments for same-file section links', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      '# Introduction\\n\\nLink to [[#introduction]]',\n      '/dir1/page-a.md'\n    );\n    workspace.set(noteA);\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references).toContainEqual(\n      expect.objectContaining({\n        label: '#introduction',\n        url: '#introduction',\n      })\n    );\n  });\n\n  it('should preserve section fragments for cross-file wikilinks without extension', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b#conclusion]]',\n      '/dir1/page-a.md'\n    );\n    const noteB = createNoteFromMarkdown(\n      '# Conclusion\\n\\nContent',\n      '/dir1/page-b.md'\n    );\n    workspace.set(noteA).set(noteB);\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references).toContainEqual(\n      expect.objectContaining({\n        label: 'page-b#conclusion',\n        url: 'page-b#conclusion',\n      })\n    );\n  });\n\n  it('should preserve section fragments for cross-file wikilinks with extension', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[page-b#introduction]]',\n      '/dir1/page-a.md'\n    );\n    const noteB = createNoteFromMarkdown(\n      '# Introduction\\n\\nContent',\n      '/dir2/page-b.md'\n    );\n    workspace.set(noteA).set(noteB);\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references).toContainEqual(\n      expect.objectContaining({\n        label: 'page-b#introduction',\n        url: '../dir2/page-b.md#introduction',\n      })\n    );\n  });\n\n  it('should preserve section fragments for embedded wikilinks with sections', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Embed ![[page-b#summary]]',\n      '/dir1/page-a.md'\n    );\n    const noteB = createNoteFromMarkdown(\n      '# Summary\\n\\nContent',\n      '/dir1/page-b.md'\n    );\n    workspace.set(noteA).set(noteB);\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references).toContainEqual(\n      expect.objectContaining({\n        label: 'page-b#summary',\n        url: 'page-b#summary',\n      })\n    );\n  });\n\n  it('should generate unambiguous URL for path-qualified wikilink when both dir1/foo and dir2/foo exist (without extension)', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[dir2/foo]]',\n      '/root/bar.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Foo in dir1', '/root/dir1/foo.md'))\n      .set(createNoteFromMarkdown('Foo in dir2', '/root/dir2/foo.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, false);\n    expect(references).toHaveLength(1);\n    expect(references[0].label).toBe('dir2/foo');\n    expect(references[0].url).toBe('dir2/foo');\n  });\n\n  it('should generate unambiguous URL for path-qualified wikilink when both dir1/foo and dir2/foo exist (with extension)', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[dir2/foo]]',\n      '/root/bar.md'\n    );\n    workspace\n      .set(noteA)\n      .set(createNoteFromMarkdown('Foo in dir1', '/root/dir1/foo.md'))\n      .set(createNoteFromMarkdown('Foo in dir2', '/root/dir2/foo.md'));\n\n    const references = createMarkdownReferences(workspace, noteA.uri, true);\n    expect(references).toHaveLength(1);\n    expect(references[0].label).toBe('dir2/foo');\n    expect(references[0].url).toBe('dir2/foo.md');\n  });\n});\n\ndescribe('Wikilink directory resolution', () => {\n  it('should resolve [[bar]] to bar/index.md when bar.md does not exist', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'bar' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(index.uri);\n  });\n\n  it('should resolve [[bar]] to bar/README.md when only README exists', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'bar' }],\n    });\n    const readme = createTestNote({ uri: '/path/to/bar/README.md' });\n    ws.set(noteA).set(readme);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(readme.uri);\n  });\n\n  it('should prefer bar.md over bar/index.md for [[bar]]', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'bar' }],\n    });\n    const file = createTestNote({ uri: '/path/to/bar.md' });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(file).set(index);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(file.uri);\n  });\n\n  it('should apply fragment to resolved directory index for [[bar#section]]', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'bar#section' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    const result = ws.resolveLink(noteA, noteA.links[0]);\n    expect(result.path).toEqual(index.uri.path);\n    expect(result.fragment).toEqual('section');\n  });\n\n  it('should not resolve [[bar]] to directory index when mode is disabled', () => {\n    const ws = createTestWorkspace([], undefined, 'disabled');\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ slug: 'bar' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    const result = ws.resolveLink(noteA, noteA.links[0]);\n    expect(result.isPlaceholder()).toBe(true);\n  });\n\n  it('should disambiguate [[zoo/bar]] to zoo/bar/index.md', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/root/page-a.md',\n      links: [{ slug: 'zoo/bar' }],\n    });\n    const fooIndex = createTestNote({ uri: '/root/foo/bar/index.md' });\n    const zooIndex = createTestNote({ uri: '/root/zoo/bar/index.md' });\n    ws.set(noteA).set(fooIndex).set(zooIndex);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(zooIndex.uri);\n  });\n});\n\ndescribe('Directory link resolution', () => {\n  it('should resolve a relative directory link to its index file', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(index.uri);\n  });\n\n  it('should resolve a relative directory link to README when no index exists', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar' }],\n    });\n    const readme = createTestNote({ uri: '/path/to/bar/README.md' });\n    ws.set(noteA).set(readme);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(readme.uri);\n  });\n\n  it('should prefer a direct file over a directory index', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar' }],\n    });\n    const file = createTestNote({ uri: '/path/to/bar.md' });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(file).set(index);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(file.uri);\n  });\n\n  it('should treat trailing slash as interchangeable with no slash', () => {\n    const ws = createTestWorkspace();\n    const noteWithSlash = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar/' }],\n    });\n    const noteWithout = createTestNote({\n      uri: '/path/to/page-b.md',\n      links: [{ to: 'bar' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteWithSlash).set(noteWithout).set(index);\n    expect(ws.resolveLink(noteWithSlash, noteWithSlash.links[0])).toEqual(\n      index.uri\n    );\n    expect(ws.resolveLink(noteWithout, noteWithout.links[0])).toEqual(\n      index.uri\n    );\n  });\n\n  it('should resolve a root-relative directory link to its index file', () => {\n    const ws = createTestWorkspace([URI.file('/workspace')]);\n    const noteA = createTestNote({\n      uri: '/workspace/page-a.md',\n      links: [{ to: '/subdir' }],\n    });\n    const index = createTestNote({ uri: '/workspace/subdir/index.md' });\n    ws.set(noteA).set(index);\n    expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(index.uri);\n  });\n\n  it('should return a placeholder when no file and no index exists', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar' }],\n    });\n    ws.set(noteA);\n    const result = ws.resolveLink(noteA, noteA.links[0]);\n    expect(result.isPlaceholder()).toBe(true);\n  });\n\n  it('should apply a fragment to the resolved directory index', () => {\n    const ws = createTestWorkspace();\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar#section' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    const result = ws.resolveLink(noteA, noteA.links[0]);\n    expect(result.path).toEqual(index.uri.path);\n    expect(result.fragment).toEqual('section');\n  });\n\n  it('should not resolve directory links when mode is disabled', () => {\n    const ws = createTestWorkspace([], undefined, 'disabled');\n    const noteA = createTestNote({\n      uri: '/path/to/page-a.md',\n      links: [{ to: 'bar' }],\n    });\n    const index = createTestNote({ uri: '/path/to/bar/index.md' });\n    ws.set(noteA).set(index);\n    const result = ws.resolveLink(noteA, noteA.links[0]);\n    expect(result.isPlaceholder()).toBe(true);\n  });\n});\n\ndescribe('Block link resolution', () => {\n  it('should resolve [[note#^blockid]] to a URI with ^blockid fragment', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[note-b#^myblock]]',\n      '/root/note-a.md'\n    );\n    const noteB = createNoteFromMarkdown(\n      'A paragraph ^myblock',\n      '/root/note-b.md'\n    );\n    workspace.set(noteA).set(noteB);\n    const result = workspace.resolveLink(noteA, noteA.links[0]);\n    expect(result.path).toEqual(noteB.uri.path);\n    expect(result.fragment).toEqual('^myblock');\n  });\n\n  it('should resolve [[#^blockid]] self-reference with ^blockid fragment', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Self link [[#^myblock]]\\n\\nA paragraph ^myblock',\n      '/root/note-a.md'\n    );\n    workspace.set(noteA);\n    const result = workspace.resolveLink(noteA, noteA.links[0]);\n    expect(result.path).toEqual(noteA.uri.path);\n    expect(result.fragment).toEqual('^myblock');\n  });\n\n  it('should resolve [[note#^blockid]] even when block does not exist (preserve fragment)', () => {\n    const workspace = createTestWorkspace();\n    const noteA = createNoteFromMarkdown(\n      'Link to [[note-b#^ghost]]',\n      '/root/note-a.md'\n    );\n    const noteB = createNoteFromMarkdown('No anchors here', '/root/note-b.md');\n    workspace.set(noteA).set(noteB);\n    const result = workspace.resolveLink(noteA, noteA.links[0]);\n    expect(result.path).toEqual(noteB.uri.path);\n    expect(result.fragment).toEqual('^ghost');\n  });\n});\n\ndescribe('readAsMarkdown with block fragments', () => {\n  const mdParser = createMarkdownParser([]);\n\n  it('should return only the block content for a paragraph block', async () => {\n    const dataStore = new InMemoryDataStore();\n    const uri = URI.file('/root/note.md');\n    dataStore.set(\n      uri,\n      'First paragraph\\n\\nTarget paragraph ^myblock\\n\\nLast paragraph'\n    );\n    const provider = new MarkdownResourceProvider(dataStore, mdParser);\n    const result = await provider.readAsMarkdown(\n      uri.with({ fragment: '^myblock' })\n    );\n    expect(result).toContain('Target paragraph');\n    expect(result).not.toContain('First paragraph');\n    expect(result).not.toContain('Last paragraph');\n    // ^id marker should be stripped from output\n    expect(result).not.toContain('^myblock');\n  });\n\n  it('should return list item and sub-items for a list block', async () => {\n    const dataStore = new InMemoryDataStore();\n    const uri = URI.file('/root/note.md');\n    dataStore.set(\n      uri,\n      '- Other item\\n- Parent item ^listblock\\n  - Child 1\\n  - Child 2\\n- Another item'\n    );\n    const provider = new MarkdownResourceProvider(dataStore, mdParser);\n    const result = await provider.readAsMarkdown(\n      uri.with({ fragment: '^listblock' })\n    );\n    expect(result).toContain('Parent item');\n    expect(result).toContain('Child 1');\n    expect(result).toContain('Child 2');\n    expect(result).not.toContain('Other item');\n    expect(result).not.toContain('Another item');\n    expect(result).not.toContain('^listblock');\n  });\n\n  it('should return full section content for a heading block', async () => {\n    const dataStore = new InMemoryDataStore();\n    const uri = URI.file('/root/note.md');\n    dataStore.set(\n      uri,\n      '# Before\\n\\nSome text\\n\\n## My Heading ^headblock\\n\\nSection content\\n\\n# After'\n    );\n    const provider = new MarkdownResourceProvider(dataStore, mdParser);\n    const result = await provider.readAsMarkdown(\n      uri.with({ fragment: '^headblock' })\n    );\n    expect(result).toContain('My Heading');\n    expect(result).toContain('Section content');\n    expect(result).not.toContain('Before');\n    expect(result).not.toContain('After');\n    expect(result).not.toContain('^headblock');\n  });\n\n  it('should return full document when block fragment is not found', async () => {\n    const dataStore = new InMemoryDataStore();\n    const uri = URI.file('/root/note.md');\n    const content = 'A paragraph\\n\\nAnother paragraph';\n    dataStore.set(uri, content);\n    const provider = new MarkdownResourceProvider(dataStore, mdParser);\n    const result = await provider.readAsMarkdown(\n      uri.with({ fragment: '^ghost' })\n    );\n    expect(result).toEqual(content);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/markdown-provider.ts",
    "content": "import {\n  NoteLinkDefinition,\n  Resource,\n  ResourceLink,\n  ResourceParser,\n} from '../model/note';\nimport { isNone, isSome } from '../utils';\nimport { Logger } from '../utils/log';\nimport { URI } from '../model/uri';\nimport { FoamWorkspace } from '../model/workspace';\nimport { IDisposable } from '../common/lifecycle';\nimport { ResourceProvider } from '../model/provider';\nimport { MarkdownLink } from './markdown-link';\nimport { IDataStore } from './datastore';\nimport { uniqBy } from 'lodash';\n\nexport class MarkdownResourceProvider implements ResourceProvider {\n  private disposables: IDisposable[] = [];\n\n  constructor(\n    private readonly dataStore: IDataStore,\n    private readonly parser: ResourceParser,\n    public readonly noteExtensions: string[] = ['.md'],\n    private readonly directoryMode: 'disabled' | 'resolve' = 'resolve'\n  ) {}\n\n  supports(uri: URI) {\n    return this.noteExtensions.includes(uri.getExtension());\n  }\n\n  async readAsMarkdown(uri: URI): Promise<string | null> {\n    let content = await this.dataStore.read(uri);\n    if (isSome(content) && uri.fragment) {\n      const resource = this.parser.parse(uri, content);\n      const rows = content.split('\\n');\n\n      if (uri.fragment.startsWith('^')) {\n        const blockId = uri.fragment.slice(1);\n        const block = Resource.findBlock(resource, blockId);\n        if (isSome(block)) {\n          let range = block.range;\n          // For heading blocks, use the section's content range instead\n          if (block.type === 'heading') {\n            const headingText = rows[block.range.start.line];\n            const headingLabel = headingText\n              .replace(/^#+\\s*/, '')\n              .replace(/\\s\\^[a-zA-Z0-9-]+$/, '');\n            const section = Resource.findSection(resource, headingLabel);\n            if (isSome(section)) {\n              range = section.range;\n              // Section ranges are exclusive at end (next heading start line)\n              content = rows\n                .slice(range.start.line, range.end.line)\n                .join('\\n')\n                .replace(/\\s\\^[a-zA-Z0-9-]+$/m, '');\n            } else {\n              // Fallback: just the heading line\n              content = rows[block.range.start.line].replace(\n                /\\s\\^[a-zA-Z0-9-]+$/,\n                ''\n              );\n            }\n          } else {\n            // AST node ranges are inclusive at end, so use end.line + 1\n            const sliced = rows\n              .slice(range.start.line, range.end.line + 1)\n              .join('\\n');\n            content = sliced.replace(/\\s\\^[a-zA-Z0-9-]+$/gm, '');\n          }\n        }\n      } else {\n        const section = Resource.findSection(resource, uri.fragment);\n        if (isSome(section)) {\n          content = rows\n            .slice(section.range.start.line, section.range.end.line)\n            .join('\\n');\n        }\n      }\n    }\n    return content;\n  }\n\n  async fetch(uri: URI) {\n    const content = await this.dataStore.read(uri);\n    return isSome(content) ? this.parser.parse(uri, content) : null;\n  }\n\n  resolveLink(\n    workspace: FoamWorkspace,\n    resource: Resource,\n    link: ResourceLink\n  ) {\n    let targetUri: URI | undefined;\n    const { target, section, blockId } = MarkdownLink.analyzeLink(link);\n    switch (link.type) {\n      case 'wikilink': {\n        if (ResourceLink.isResolvedReference(link)) {\n          const definedUri = resource.uri.resolve(link.definition.url);\n          targetUri =\n            workspace.find(definedUri, resource.uri)?.uri ??\n            URI.placeholder(definedUri.path);\n          if (definedUri.fragment) {\n            targetUri = targetUri.with({ fragment: definedUri.fragment });\n          }\n        } else {\n          targetUri =\n            target === ''\n              ? resource.uri\n              : workspace.find(target, resource.uri)?.uri ??\n                this._resolveDirectoryByIdentifier(workspace, target)?.uri ??\n                URI.placeholder(target);\n          if (blockId) {\n            targetUri = targetUri.with({ fragment: `^${blockId}` });\n          } else if (section) {\n            targetUri = targetUri.with({ fragment: section });\n          }\n        }\n        break;\n      }\n      case 'link': {\n        if (ResourceLink.isUnresolvedReference(link)) {\n          // Reference-style link with unresolved reference - treat as placeholder\n          targetUri = URI.placeholder(link.definition);\n          break;\n        }\n\n        // Handle reference-style links first; strip trailing slash (directory links)\n        const targetPath = (\n          ResourceLink.isResolvedReference(link) ? link.definition.url : target\n        ).replace(/\\/$/, '');\n\n        let path: string;\n\n        if (targetPath.startsWith('/')) {\n          const resolvedUri = workspace.resolveUri(targetPath);\n          targetUri =\n            workspace.find(targetPath, resource.uri)?.uri ??\n            workspace.roots\n              .map(root =>\n                this._resolveAsDirectory(workspace, root.joinPath(targetPath))\n              )\n              .find(Boolean)?.uri ??\n            URI.placeholder(resolvedUri.path);\n        } else {\n          // Handle relative paths and non-root paths\n          path =\n            targetPath.startsWith('./') || targetPath.startsWith('../')\n              ? targetPath\n              : './' + targetPath;\n          // Use getDirectory().joinPath() rather than URI.resolve() to avoid\n          // inheriting the parent's .md extension on files with no extension\n          // (e.g. dotfiles like .editorconfig, where posix.extname returns '').\n          // See: https://github.com/foambubble/foam/issues/1379\n          const directResolvedUri = resource.uri.getDirectory().joinPath(path);\n          targetUri =\n            workspace.find(path, resource.uri)?.uri ??\n            this._resolveAsDirectory(workspace, directResolvedUri)?.uri ??\n            URI.placeholder(directResolvedUri.path);\n        }\n\n        if (section && !targetUri.isPlaceholder()) {\n          targetUri = targetUri.with({ fragment: section });\n        }\n        break;\n      }\n    }\n    return targetUri;\n  }\n\n  private _resolveAsDirectory(\n    workspace: FoamWorkspace,\n    resolvedDirUri: URI\n  ): Resource | null {\n    if (this.directoryMode !== 'resolve') return null;\n    return workspace.findByDirectory(resolvedDirUri.path);\n  }\n\n  private _resolveDirectoryByIdentifier(\n    workspace: FoamWorkspace,\n    identifier: string\n  ): Resource | null {\n    if (this.directoryMode !== 'resolve') return null;\n    return workspace.listByDirectoryIdentifier(identifier)[0] ?? null;\n  }\n\n  dispose() {\n    this.disposables.forEach(d => d.dispose());\n  }\n}\n\nexport function createMarkdownReferences(\n  workspace: FoamWorkspace,\n  source: Resource | URI,\n  includeExtension: boolean\n): NoteLinkDefinition[] {\n  const resource = source instanceof URI ? workspace.find(source) : source;\n\n  const definitions = resource.links\n    .filter(link => ResourceLink.isReferenceStyleLink(link))\n    .map(link => {\n      if (ResourceLink.isResolvedReference(link)) {\n        return link.definition;\n      }\n\n      const targetUri = workspace.resolveLink(resource, link);\n      const target = workspace.find(targetUri);\n      if (isNone(target)) {\n        Logger.warn(\n          `Link ${targetUri.toString()} in ${resource.uri.toString()} is not valid.`\n        );\n        return null;\n      }\n      if (target.type === 'placeholder') {\n        // no need to create definitions for placeholders\n        return null;\n      }\n\n      // Special handling for same-file section links (e.g., [[#section]])\n      if (target.uri.isEqual(resource.uri) && targetUri.fragment) {\n        return {\n          label: link.rawText.substring(\n            link.isEmbed ? 3 : 2,\n            link.rawText.length - 2\n          ),\n          url: `#${targetUri.fragment}`,\n          title: target.title,\n        };\n      }\n\n      let relativeUri = target.uri.relativeTo(resource.uri.getDirectory());\n      if (\n        !includeExtension &&\n        relativeUri.path.endsWith(workspace.defaultExtension)\n      ) {\n        relativeUri = relativeUri.changeExtension('*', '');\n      }\n\n      // Extract base path and link name separately.\n      const basePath = relativeUri.path.split('/').slice(0, -1).join('/');\n      const linkName = relativeUri.path.split('/').pop();\n\n      const encodedURL = encodeURIComponent(linkName).replace(/%20/g, ' ');\n\n      // [wikilink-text]: path/to/file.md \"Page title\"\n      // Build the base URL\n      let url = `${basePath ? basePath + '/' : ''}${encodedURL}`;\n\n      // Append fragment from targetUri if it exists\n      if (targetUri.fragment) {\n        url += `#${targetUri.fragment}`;\n      }\n\n      // [wikilink-text]: path/to/file.md#section \"Page title\"\n      return {\n        // embedded looks like ![[note-a]]\n        // regular note looks like [[note-a]]\n        label: link.rawText.substring(\n          link.isEmbed ? 3 : 2,\n          link.rawText.length - 2\n        ),\n        url: url,\n        title: target.title,\n      };\n    })\n    .filter(isSome)\n    .sort();\n  return uniqBy(definitions, def => NoteLinkDefinition.format(def));\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/progress.ts",
    "content": "/**\n * Generic progress information for long-running operations\n */\nexport interface Progress<T = unknown> {\n  /** Current item being processed (1-indexed) */\n  current: number;\n  /** Total number of items to process */\n  total: number;\n  /** Optional context data about the current item */\n  context?: T;\n}\n\n/**\n * Callback for reporting progress during operations\n */\nexport type ProgressCallback<T = unknown> = (progress: Progress<T>) => void;\n\n/**\n * Cancellation token for aborting long-running operations\n */\nexport interface CancellationToken {\n  /** Whether cancellation has been requested */\n  readonly isCancellationRequested: boolean;\n}\n\n/**\n * Exception thrown when an operation is cancelled\n */\nexport class CancellationError extends Error {\n  constructor(message: string = 'Operation cancelled') {\n    super(message);\n    this.name = 'CancellationError';\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/tag-edit.test.ts",
    "content": "import { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { FoamTags } from '../model/tags';\nimport { TagEdit } from './tag-edit';\nimport { Range } from '../model/range';\nimport { Position } from '../model/position';\nimport { URI } from '../model/uri';\n\ndescribe('TagEdit', () => {\n  describe('createRenameTagEdits', () => {\n    it('should generate edits for all occurrences of a tag', () => {\n      const ws = createTestWorkspace();\n\n      const pageA = createTestNote({\n        uri: '/page-a.md',\n        title: 'Page A',\n        tags: ['oldtag', 'anothertag'],\n      });\n\n      // Manually set the ranges for testing\n      pageA.tags[0].range = Range.create(0, 5, 0, 11);\n      pageA.tags[1].range = Range.create(1, 5, 1, 15);\n\n      const pageB = createTestNote({\n        uri: '/page-b.md',\n        title: 'Page B',\n        tags: ['oldtag'],\n      });\n\n      // Manually set the range for testing\n      pageB.tags[0].range = Range.create(2, 10, 2, 16);\n\n      ws.set(pageA);\n      ws.set(pageB);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createRenameTagEdits(foamTags, 'oldtag', 'newtag');\n\n      expect(result.totalOccurrences).toBe(2);\n      expect(result.edits).toHaveLength(2);\n\n      // Check edits - should contain one edit for each page\n      const pageAEdit = result.edits.find(\n        e => e.uri.toString() === 'file:///page-a.md'\n      );\n      expect(pageAEdit).toBeDefined();\n      expect(pageAEdit!.edit).toEqual({\n        range: Range.create(0, 5, 0, 11),\n        newText: 'newtag',\n      });\n\n      const pageBEdit = result.edits.find(\n        e => e.uri.toString() === 'file:///page-b.md'\n      );\n      expect(pageBEdit).toBeDefined();\n      expect(pageBEdit!.edit).toEqual({\n        range: Range.create(2, 10, 2, 16),\n        newText: 'newtag',\n      });\n    });\n\n    it('should return empty result when tag does not exist', () => {\n      const ws = createTestWorkspace();\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      const result = TagEdit.createRenameTagEdits(\n        foamTags,\n        'nonexistent',\n        'newtag'\n      );\n\n      expect(result.totalOccurrences).toBe(0);\n      expect(result.edits).toHaveLength(0);\n    });\n\n    it('should handle multiple edits in the same file', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['duplicatetag', 'duplicatetag'],\n      });\n\n      // Manually set the ranges for testing\n      page.tags[0].range = Range.create(0, 5, 0, 17);\n      page.tags[1].range = Range.create(5, 10, 5, 22);\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createRenameTagEdits(\n        foamTags,\n        'duplicatetag',\n        'newtag'\n      );\n\n      expect(result.totalOccurrences).toBe(2);\n      expect(result.edits).toHaveLength(2);\n\n      // Filter edits for the specific page\n      const pageEdits = result.edits.filter(e => e.uri.isEqual(page.uri));\n      expect(pageEdits).toHaveLength(2);\n      expect(pageEdits.map(e => e.edit)).toEqual([\n        {\n          range: Range.create(0, 5, 0, 17),\n          newText: 'newtag',\n        },\n        {\n          range: Range.create(5, 10, 5, 22),\n          newText: 'newtag',\n        },\n      ]);\n    });\n\n    it('should preserve # prefix for hashtag-style tags', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['hashtag'],\n      });\n\n      // Simulate a hashtag range that includes the # prefix (length = label + 1)\n      page.tags[0].range = Range.create(0, 5, 0, 13); // \"#hashtag\" = 8 chars\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createRenameTagEdits(\n        foamTags,\n        'hashtag',\n        'newtag'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n\n      const pageEdit = result.edits[0];\n      expect(pageEdit.uri.toString()).toBe('file:///page.md');\n      expect(pageEdit.edit).toEqual({\n        range: Range.create(0, 5, 0, 13),\n        newText: '#newtag', // Should include # prefix\n      });\n    });\n\n    it('should not add # prefix for YAML-style tags', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['yamltag'],\n      });\n\n      // Simulate a YAML tag range that does not include # prefix (length = label only)\n      page.tags[0].range = Range.create(0, 5, 0, 12); // \"yamltag\" = 7 chars\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createRenameTagEdits(\n        foamTags,\n        'yamltag',\n        'newtag'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n\n      const pageEdit = result.edits[0];\n      expect(pageEdit.uri.toString()).toBe('file:///page.md');\n      expect(pageEdit.edit).toEqual({\n        range: Range.create(0, 5, 0, 12),\n        newText: 'newtag', // Should not include # prefix\n      });\n    });\n  });\n\n  describe('validateTagRename', () => {\n    it('should accept valid tag rename', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'newtag');\n\n      expect(result.isValid).toBe(true);\n      expect(result.message).toBeUndefined();\n    });\n\n    it('should reject rename of non-existent tag', () => {\n      const ws = createTestWorkspace();\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      const result = TagEdit.validateTagRename(\n        foamTags,\n        'nonexistent',\n        'newtag'\n      );\n\n      expect(result.isValid).toBe(false);\n      expect(result.message).toContain('does not exist');\n    });\n\n    it('should reject empty new tag name', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(foamTags, 'oldtag', '');\n\n      expect(result.isValid).toBe(false);\n      expect(result.message).toContain('cannot be empty');\n    });\n\n    it('should detect merge when renaming to existing tag', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag', 'existingtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(\n        foamTags,\n        'oldtag',\n        'existingtag'\n      );\n\n      expect(result.isValid).toBe(true);\n      expect(result.isMerge).toBe(true);\n      expect(result.sourceOccurrences).toBe(1);\n      expect(result.targetOccurrences).toBe(1);\n      expect(result.message).toContain('merge');\n      expect(result.message).toContain('oldtag');\n      expect(result.message).toContain('existingtag');\n    });\n\n    it('should reject tag names with spaces', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'new tag');\n\n      expect(result.isValid).toBe(false);\n      expect(result.message).toContain('Invalid tag label');\n    });\n\n    it('should handle new tag name with # prefix', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(foamTags, 'oldtag', '#newtag');\n\n      expect(result.isValid).toBe(true);\n      expect(result.isMerge).toBe(false);\n      expect(result.sourceOccurrences).toBe(1);\n      expect(result.targetOccurrences).toBe(0);\n      expect(result.message).toBeUndefined();\n    });\n\n    it('should reject renaming to same tag name', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['oldtag'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'oldtag');\n\n      expect(result.isValid).toBe(false);\n      expect(result.isMerge).toBe(false);\n      expect(result.sourceOccurrences).toBe(1);\n      expect(result.targetOccurrences).toBe(1);\n      expect(result.message).toContain('same as the current name');\n    });\n  });\n\n  describe('findChildTags', () => {\n    it('should find direct child tags', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['project', 'project/frontend', 'project/backend', 'other'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const childTags = TagEdit.findChildTags(foamTags, 'project');\n\n      expect(childTags).toEqual(['project/backend', 'project/frontend']);\n    });\n\n    it('should find nested child tags', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: [\n          'project',\n          'project/frontend',\n          'project/frontend/react',\n          'project/backend',\n          'project/backend/api',\n          'other',\n        ],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const childTags = TagEdit.findChildTags(foamTags, 'project');\n\n      expect(childTags).toEqual([\n        'project/backend',\n        'project/backend/api',\n        'project/frontend',\n        'project/frontend/react',\n      ]);\n    });\n\n    it('should return empty array when no child tags exist', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['project', 'other', 'standalone'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const childTags = TagEdit.findChildTags(foamTags, 'project');\n\n      expect(childTags).toEqual([]);\n    });\n\n    it('should not return partial matches', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['project', 'projectile', 'project-old'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const childTags = TagEdit.findChildTags(foamTags, 'project');\n\n      expect(childTags).toEqual([]);\n    });\n  });\n\n  describe('createHierarchicalRenameEdits', () => {\n    it('should rename parent and all child tags', () => {\n      const ws = createTestWorkspace();\n\n      const pageA = createTestNote({\n        uri: '/page-a.md',\n        title: 'Page A',\n        tags: ['project', 'project/frontend'],\n      });\n\n      const pageB = createTestNote({\n        uri: '/page-b.md',\n        title: 'Page B',\n        tags: ['project/backend', 'other'],\n      });\n\n      ws.set(pageA);\n      ws.set(pageB);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createHierarchicalRenameEdits(\n        foamTags,\n        'project',\n        'work'\n      );\n\n      expect(result.totalOccurrences).toBe(3); // project, project/frontend, project/backend\n      expect(result.edits).toHaveLength(3);\n\n      // Check that all expected tags are renamed\n      const editedTags = result.edits.map(edit => edit.edit.newText);\n      expect(editedTags).toContain('work');\n      expect(editedTags).toContain('work/frontend');\n      expect(editedTags).toContain('work/backend');\n    });\n\n    it('should handle nested hierarchies correctly', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['project', 'project/frontend', 'project/frontend/react'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createHierarchicalRenameEdits(\n        foamTags,\n        'project',\n        'work'\n      );\n\n      expect(result.totalOccurrences).toBe(3);\n\n      const editedTags = result.edits.map(edit => edit.edit.newText);\n      expect(editedTags).toContain('work');\n      expect(editedTags).toContain('work/frontend');\n      expect(editedTags).toContain('work/frontend/react');\n    });\n\n    it('should work when parent tag has no children', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['standalone', 'other'],\n      });\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n      const result = TagEdit.createHierarchicalRenameEdits(\n        foamTags,\n        'standalone',\n        'single'\n      );\n\n      expect(result.totalOccurrences).toBe(1);\n      expect(result.edits).toHaveLength(1);\n      expect(result.edits[0].edit.newText).toBe('single');\n    });\n  });\n\n  describe('getTagAtPosition', () => {\n    it('should find tag at exact position', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['testtag'],\n      });\n\n      // Manually set the range for testing\n      page.tags[0].range = Range.create(0, 5, 0, 12);\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      // Test positions within the tag range\n      const pageUri = URI.parse('file:///page.md', 'file');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 5))\n      ).toBe('testtag');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 8))\n      ).toBe('testtag');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 12))\n      ).toBe('testtag');\n\n      // Test positions outside the tag range\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 4))\n      ).toBeUndefined();\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 13))\n      ).toBeUndefined();\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 5))\n      ).toBeUndefined();\n    });\n\n    it('should return undefined for non-existent file', () => {\n      const ws = createTestWorkspace();\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      const nonexistentUri = URI.parse('file:///nonexistent.md', 'file');\n      expect(\n        TagEdit.getTagAtPosition(\n          foamTags,\n          nonexistentUri,\n          Position.create(0, 5)\n        )\n      ).toBeUndefined();\n    });\n\n    it('should handle multiple tags and return the correct one', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['firsttag', 'secondtag'],\n      });\n\n      // Manually set the ranges for testing\n      page.tags[0].range = Range.create(0, 5, 0, 13);\n      page.tags[1].range = Range.create(0, 20, 0, 29);\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      // Should return the correct tag for each position\n      const pageUri = URI.parse('file:///page.md', 'file');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 8))\n      ).toBe('firsttag');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 25))\n      ).toBe('secondtag');\n\n      // Position between tags should return undefined\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 15))\n      ).toBeUndefined();\n    });\n\n    it('should handle multiline tags', () => {\n      const ws = createTestWorkspace();\n\n      const page = createTestNote({\n        uri: '/page.md',\n        title: 'Page',\n        tags: ['multilinetag'],\n      });\n\n      // Manually set the range for testing\n      page.tags[0].range = Range.create(1, 10, 3, 5);\n\n      ws.set(page);\n\n      const foamTags = FoamTags.fromWorkspace(ws);\n\n      // Should find tag on different lines within the range\n      const pageUri = URI.parse('file:///page.md', 'file');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 15))\n      ).toBe('multilinetag');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(2, 0))\n      ).toBe('multilinetag');\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(3, 3))\n      ).toBe('multilinetag');\n\n      // Should not find tag outside the range\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 5))\n      ).toBeUndefined();\n      expect(\n        TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(3, 10))\n      ).toBeUndefined();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/tag-edit.ts",
    "content": "import { FoamTags } from '../model/tags';\nimport { TextEdit, WorkspaceTextEdit } from './text-edit';\nimport { Location } from '../model/location';\nimport { Tag } from '../model/note';\nimport { URI } from '../model/uri';\nimport { Range } from '../model/range';\nimport { Position } from '../model/position';\nimport { WORD_REGEX } from '../utils/hashtags';\n\n/**\n * Result object containing all information needed to perform a tag rename operation.\n */\nexport interface TagEditResult {\n  /**\n   * Array of workspace text edits to perform the tag rename operation.\n   */\n  edits: WorkspaceTextEdit[];\n\n  /**\n   * Total number of tag occurrences that will be renamed across all files.\n   */\n  totalOccurrences: number;\n}\n\n/**\n * Utility class for performing tag editing operations in Foam workspaces.\n * Provides functionality to rename tags across multiple files while maintaining\n * consistency and data integrity.\n */\nexport abstract class TagEdit {\n  /**\n   * Generate text edits to rename a tag across the workspace.\n   *\n   * @param foamTags The FoamTags instance containing all tag locations\n   * @param oldTagLabel The current tag label to rename (without # prefix)\n   * @param newTagLabel The new tag label (without # prefix)\n   * @returns TagEditResult containing all necessary workspace text edits\n   */\n  public static createRenameTagEdits(\n    foamTags: FoamTags,\n    oldTagLabel: string,\n    newTagLabel: string\n  ): TagEditResult {\n    const tagLocations = foamTags.tags.get(oldTagLabel) ?? [];\n    const workspaceEdits: WorkspaceTextEdit[] = [];\n\n    for (const location of tagLocations) {\n      const textEdit = this.createSingleTagEdit(\n        location,\n        oldTagLabel,\n        newTagLabel\n      );\n      workspaceEdits.push({\n        uri: location.uri,\n        edit: textEdit,\n      });\n    }\n\n    return {\n      edits: workspaceEdits,\n      totalOccurrences: tagLocations.length,\n    };\n  }\n\n  /**\n   * Create a single text edit for a tag location.\n   *\n   * @param location The location of the tag to rename\n   * @param oldTagLabel The current tag label to determine original format\n   * @param newTagLabel The new tag label to replace with\n   * @returns TextEdit for this specific tag occurrence\n   */\n  private static createSingleTagEdit(\n    location: Location<Tag>,\n    oldTagLabel: string,\n    newTagLabel: string\n  ): TextEdit {\n    const range = location.range;\n    const rangeLength = range.end.character - range.start.character;\n\n    // If range length is tag label length + 1, it's a hashtag (includes #)\n    // If range length equals tag label length, it's a YAML tag (no #)\n    const isHashtag = rangeLength === oldTagLabel.length + 1;\n\n    const newText = isHashtag ? `#${newTagLabel}` : newTagLabel;\n\n    return {\n      range: location.range,\n      newText,\n    };\n  }\n\n  /**\n   * Validate if a tag rename operation is safe and allowed.\n   *\n   * @param foamTags The FoamTags instance containing current tag information\n   * @param oldTagLabel The tag being renamed (must exist in workspace)\n   * @param newTagLabel The proposed new tag label (will be cleaned of # prefix)\n   * @returns Validation result with merge information and statistics\n   */\n  public static validateTagRename(\n    foamTags: FoamTags,\n    oldTagLabel: string,\n    newTagLabel: string\n  ): {\n    isValid: boolean;\n    isMerge: boolean;\n    sourceOccurrences: number;\n    targetOccurrences: number;\n    message?: string;\n  } {\n    const sourceOccurrences = foamTags.tags.get(oldTagLabel)?.length ?? 0;\n\n    // Check if old tag exists\n    if (!foamTags.tags.has(oldTagLabel)) {\n      return {\n        isValid: false,\n        isMerge: false,\n        sourceOccurrences: 0,\n        targetOccurrences: 0,\n        message: `Tag \"${oldTagLabel}\" does not exist in the workspace.`,\n      };\n    }\n\n    // Clean the new tag label (remove # if present)\n    const cleanNewLabel = newTagLabel?.startsWith('#')\n      ? newTagLabel.substring(1)\n      : newTagLabel;\n\n    // Check if new tag label is empty or invalid\n    if (!cleanNewLabel || cleanNewLabel.trim() === '') {\n      return {\n        isValid: false,\n        isMerge: false,\n        sourceOccurrences,\n        targetOccurrences: 0,\n        message: 'New tag label cannot be empty.',\n      };\n    }\n\n    // Check for invalid characters in tag label\n    const match = cleanNewLabel.match(WORD_REGEX);\n    if (!match || match[0] !== cleanNewLabel) {\n      return {\n        isValid: false,\n        isMerge: false,\n        sourceOccurrences,\n        targetOccurrences: 0,\n        message: 'Invalid tag label.',\n      };\n    }\n\n    // Check if renaming to same tag (no-op)\n    if (cleanNewLabel === oldTagLabel) {\n      return {\n        isValid: false,\n        isMerge: false,\n        sourceOccurrences,\n        targetOccurrences: sourceOccurrences,\n        message: 'New tag name is the same as the current name.',\n      };\n    }\n\n    const targetOccurrences = foamTags.tags.get(cleanNewLabel)?.length ?? 0;\n    const isMerge = foamTags.tags.has(cleanNewLabel);\n\n    return {\n      isValid: true,\n      isMerge: isMerge,\n      sourceOccurrences,\n      targetOccurrences,\n      message: isMerge\n        ? `This will merge \"${oldTagLabel}\" (${sourceOccurrences} occurrence${\n            sourceOccurrences !== 1 ? 's' : ''\n          }) into \"${cleanNewLabel}\" (${targetOccurrences} occurrence${\n            targetOccurrences !== 1 ? 's' : ''\n          })`\n        : undefined,\n    };\n  }\n\n  /**\n   * Find all child tags for a given parent tag.\n   *\n   * This method searches for tags that start with the parent tag followed by\n   * a forward slash, indicating they are hierarchical children.\n   *\n   * @param foamTags The FoamTags instance containing all tag information\n   * @param parentTag The parent tag to find children for (e.g., \"project\")\n   * @returns Array of child tag labels (e.g., [\"project/frontend\", \"project/backend\"])\n   */\n  public static findChildTags(foamTags: FoamTags, parentTag: string): string[] {\n    const childTags: string[] = [];\n    const parentPrefix = parentTag + '/';\n\n    for (const [tagLabel] of foamTags.tags) {\n      if (tagLabel.startsWith(parentPrefix)) {\n        childTags.push(tagLabel);\n      }\n    }\n\n    return childTags.sort();\n  }\n\n  /**\n   * Create text edits to rename a parent tag and all its children hierarchically.\n   *\n   * This method performs a comprehensive rename operation that updates both\n   * the parent tag and all child tags, maintaining the hierarchical structure\n   * with the new parent name.\n   *\n   * @param foamTags The FoamTags instance containing all tag locations\n   * @param oldParentTag The current parent tag label (without # prefix)\n   * @param newParentTag The new parent tag label (without # prefix)\n   * @returns TagEditResult containing all necessary workspace text edits\n   */\n  public static createHierarchicalRenameEdits(\n    foamTags: FoamTags,\n    oldParentTag: string,\n    newParentTag: string\n  ): TagEditResult {\n    const allEdits: WorkspaceTextEdit[] = [];\n    let totalOccurrences = 0;\n\n    // Rename the parent tag itself\n    const parentResult = this.createRenameTagEdits(\n      foamTags,\n      oldParentTag,\n      newParentTag\n    );\n    allEdits.push(...parentResult.edits);\n    totalOccurrences += parentResult.totalOccurrences;\n\n    // Find and rename all child tags\n    const childTags = this.findChildTags(foamTags, oldParentTag);\n    for (const childTag of childTags) {\n      // Replace the parent portion with the new parent name\n      const newChildTag = childTag.replace(\n        oldParentTag + '/',\n        newParentTag + '/'\n      );\n      const childResult = this.createRenameTagEdits(\n        foamTags,\n        childTag,\n        newChildTag\n      );\n      allEdits.push(...childResult.edits);\n      totalOccurrences += childResult.totalOccurrences;\n    }\n\n    return {\n      edits: allEdits,\n      totalOccurrences,\n    };\n  }\n\n  /**\n   * Find the tag at a specific position in a document.\n   *\n   * @param foamTags The FoamTags instance containing all tag location data\n   * @param uri The URI of the file to search in\n   * @param position The position in the document (line and character)\n   * @returns The tag label if a tag is found at the position, undefined otherwise\n   */\n  public static getTagAtPosition(\n    foamTags: FoamTags,\n    uri: URI,\n    position: Position\n  ): string | undefined {\n    // Search through all tags to find one that contains the given position\n    for (const [tagLabel, locations] of foamTags.tags) {\n      for (const location of locations) {\n        if (!location.uri.isEqual(uri)) {\n          continue;\n        }\n        if (Range.containsPosition(location.range, position)) {\n          return tagLabel;\n        }\n      }\n    }\n\n    return undefined;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/text-edit.test.ts",
    "content": "import { Range } from '../model/range';\nimport { Logger } from '../utils/log';\nimport { TextEdit } from './text-edit';\n\nLogger.setLevel('error');\n\ndescribe('applyTextEdit', () => {\n  it('should return text with applied TextEdit in the end of the string', () => {\n    const textEdit = {\n      newText: `4. this is fourth line`,\n      range: Range.create(4, 0, 4, 0),\n    };\n\n    const text = `\n1. this is first line\n2. this is second line\n3. this is third line\n`;\n\n    const expected = `\n1. this is first line\n2. this is second line\n3. this is third line\n4. this is fourth line`;\n\n    const actual = TextEdit.apply(text, textEdit);\n\n    expect(actual).toBe(expected);\n  });\n\n  it('should return text with applied TextEdit at the top of the string', () => {\n    const textEdit = {\n      newText: `1. this is first line\\n`,\n      range: Range.create(1, 0, 1, 0),\n    };\n\n    const text = `\n2. this is second line\n3. this is third line\n`;\n\n    const expected = `\n1. this is first line\n2. this is second line\n3. this is third line\n`;\n\n    const actual = TextEdit.apply(text, textEdit);\n\n    expect(actual).toBe(expected);\n  });\n\n  it('should return text with applied TextEdit in the middle of the string', () => {\n    const textEdit = {\n      newText: `2. this is the updated second line`,\n      range: Range.create(2, 0, 2, 100),\n    };\n\n    const text = `\n1. this is first line\n2. this is second line\n3. this is third line\n`;\n\n    const expected = `\n1. this is first line\n2. this is the updated second line\n3. this is third line\n`;\n\n    const actual = TextEdit.apply(text, textEdit);\n\n    expect(actual).toBe(expected);\n  });\n\n  it('should apply multiple TextEdits in reverse order (VS Code behavior)', () => {\n    // This test shows why reverse order is important for range stability\n    const textEdits = [\n      // Edit near beginning - would affect later ranges if applied first\n      {\n        newText: `[PREFIX] `,\n        range: Range.create(0, 0, 0, 0),\n      },\n      // Edit in middle - range stays valid with reverse order\n      {\n        newText: `[MIDDLE] `,\n        range: Range.create(0, 11, 0, 11),\n      },\n      // Edit at end - applied first, doesn't affect other ranges\n      {\n        newText: ` [END]`,\n        range: Range.create(0, 15, 0, 15),\n      },\n    ];\n\n    const text = `this is my text`;\n    const expected = `[PREFIX] this is my [MIDDLE] text [END]`;\n\n    const actual = TextEdit.apply(text, textEdits);\n\n    expect(actual).toBe(expected);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/services/text-edit.ts",
    "content": "import detectNewline from 'detect-newline';\nimport { Position } from '../model/position';\nimport { Range } from '../model/range';\nimport { URI } from '../model/uri';\n\nexport interface TextEdit {\n  range: Range;\n  newText: string;\n}\n\nexport abstract class TextEdit {\n  /**\n   *\n   * @param text text on which the textEdit will be applied\n   * @param textEdit\n   * @returns {string} text with the applied textEdit\n   */\n  public static apply(text: string, textEdit: TextEdit): string;\n  // eslint-disable-next-line no-dupe-class-members\n  public static apply(text: string, textEdits: TextEdit[]): string;\n  // eslint-disable-next-line no-dupe-class-members\n  public static apply(\n    text: string,\n    textEditOrEdits: TextEdit | TextEdit[]\n  ): string {\n    if (Array.isArray(textEditOrEdits)) {\n      // Apply edits in reverse order (end-to-beginning) to maintain range validity\n      // This matches VS Code's behavior for TextEdit application\n      const sortedEdits = [...textEditOrEdits].sort((a, b) =>\n        Position.compareTo(b.range.start, a.range.start)\n      );\n      let result = text;\n      for (const textEdit of sortedEdits) {\n        result = this.apply(result, textEdit);\n      }\n      return result;\n    }\n\n    const textEdit = textEditOrEdits;\n    const eol = detectNewline.graceful(text);\n    const lines = text.split(eol);\n    const characters = text.split('');\n    const startOffset = getOffset(lines, textEdit.range.start, eol);\n    const endOffset = getOffset(lines, textEdit.range.end, eol);\n    const deleteCount = endOffset - startOffset;\n\n    const textToAppend = `${textEdit.newText}`;\n    characters.splice(startOffset, deleteCount, textToAppend);\n    return characters.join('');\n  }\n}\n\nconst getOffset = (\n  lines: string[],\n  position: Position,\n  eol: string\n): number => {\n  const eolLen = eol.length;\n  let offset = 0;\n  let i = 0;\n  while (i < position.line && i < lines.length) {\n    offset = offset + lines[i].length + eolLen;\n    i++;\n  }\n  return offset + Math.min(position.character, lines[i]?.length ?? 0);\n};\n\n/**\n * A text edit with workspace context, combining a URI location with the edit operation.\n *\n * This interface uses composition to pair a text edit with its file location,\n * providing a self-contained unit for workspace-wide text modifications.\n */\nexport interface WorkspaceTextEdit {\n  /** The URI of the file where this edit should be applied */\n  uri: URI;\n  /** The text edit operation to perform */\n  edit: TextEdit;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/cache.ts",
    "content": "export interface ICache<K, V> {\n  get(key: K): V | undefined;\n  has(key: K): boolean;\n  set(key: K, data: V): void;\n  del(key: K): void;\n  clear(): void;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/core.ts",
    "content": "import sha1 from 'js-sha1';\n\n/**\n * Checks if a value is not null.\n *\n * @param value - The value to check.\n * @returns True if the value is not null, otherwise false.\n */\nexport function isNotNull<T>(value: T | null): value is T {\n  return value != null;\n}\n\n/**\n * Checks if a value is not null, undefined, or void.\n *\n * @param value - The value to check.\n * @returns True if the value is not null, undefined, or void, otherwise false.\n */\nexport function isSome<T>(\n  value: T | null | undefined | void\n): value is NonNullable<T> {\n  return value != null;\n}\n\n/**\n * Checks if a value is null, undefined, or void.\n *\n * @param value - The value to check.\n * @returns True if the value is null, undefined, or void, otherwise false.\n */\nexport function isNone<T>(\n  value: T | null | undefined | void\n): value is null | undefined | void {\n  return value == null;\n}\n\n/**\n * Checks if a string is numeric.\n *\n * @param value - The string to check.\n * @returns True if the string is numeric, otherwise false.\n */\nexport function isNumeric(value: string): boolean {\n  return /-?\\d+$/.test(value);\n}\n\n/**\n * Generates a SHA-1 hash of the given text.\n *\n * @param text - The text to hash.\n * @returns The SHA-1 hash of the text.\n */\nexport const hash = (text: string) => sha1.sha1(text);\n\n/**\n * Executes an array of functions and returns the first result that satisfies the predicate.\n *\n * @param functions - The array of functions to execute.\n * @param predicate - The predicate to test the results. Defaults to checking if the result is not null.\n * @returns The first result that satisfies the predicate, or undefined if no result satisfies the predicate.\n */\nexport async function firstFrom<T>(\n  functions: Array<() => T | Promise<T>>,\n  predicate: (result: T) => boolean = result => result != null\n): Promise<T | undefined> {\n  for (const fn of functions) {\n    const result = await fn();\n    if (predicate(result)) {\n      return result;\n    }\n  }\n  return undefined;\n}\n\n/**\n * Lazily executes an array of functions and yields their results.\n *\n * @param functions - The array of functions to execute.\n * @returns A generator yielding the results of the functions.\n */\nexport function* lazyExecutor<T>(functions: Array<() => T>): Generator<T> {\n  for (const fn of functions) {\n    yield fn();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/hashtags.ts",
    "content": "import { isSome } from './core';\nexport const HASHTAG_REGEX =\n  /(?<=^|\\s)#([0-9]*[\\p{L}\\p{Extended_Pictographic}/_-](?:[\\p{L}\\p{Extended_Pictographic}\\p{N}/_-]|\\uFE0F|\\p{Emoji_Modifier})*)/gmu;\nexport const WORD_REGEX =\n  /(?<=^|\\s)([0-9]*[\\p{L}\\p{Extended_Pictographic}/_-](?:[\\p{L}\\p{Extended_Pictographic}\\p{N}/_-]|\\uFE0F|\\p{Emoji_Modifier})*)/gmu;\n\nexport const extractHashtags = (\n  text: string\n): Array<{ label: string; offset: number }> => {\n  return isSome(text)\n    ? Array.from(text.matchAll(HASHTAG_REGEX)).map(m => ({\n        label: m[1],\n        offset: m.index!,\n      }))\n    : [];\n};\n\nexport const extractTagsFromProp = (prop: string | string[]): string[] => {\n  const text = Array.isArray(prop) ? prop.join(' ') : prop;\n  return isSome(text)\n    ? Array.from(text.matchAll(WORD_REGEX)).map(m => m[1])\n    : [];\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/index.ts",
    "content": "import { titleCase } from 'title-case';\nexport { extractHashtags, extractTagsFromProp } from './hashtags';\nexport * from './core';\n\n/**\n *\n * @param filename\n * @returns title cased heading after removing special characters\n */\nexport const getHeadingFromFileName = (filename: string): string => {\n  return titleCase(filename.replace(/[^\\w\\s]/gi, ' '));\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/log.ts",
    "content": "export interface ILogger {\n  debug(message?: any, ...params: any[]): void;\n  info(message?: any, ...params: any[]): void;\n  warn(message?: any, ...params: any[]): void;\n  error(message?: any, ...params: any[]): void;\n  getLevel(): LogLevelThreshold;\n  setLevel(level: LogLevelThreshold): void;\n}\n\nexport type LogLevel = 'debug' | 'info' | 'warn' | 'error';\nexport type LogLevelThreshold = LogLevel | 'off';\n\nexport abstract class BaseLogger implements ILogger {\n  private static severity = {\n    debug: 1,\n    info: 2,\n    warn: 3,\n    error: 4,\n  };\n\n  constructor(private level: LogLevelThreshold = 'info') {}\n\n  abstract log(lvl: LogLevel, msg?: any, ...extra: any[]): void;\n\n  doLog(msgLevel: LogLevel, message?: any, ...params: any[]): void {\n    if (this.level === 'off') {\n      return;\n    }\n    if (BaseLogger.severity[msgLevel] >= BaseLogger.severity[this.level]) {\n      this.log(msgLevel, message, ...params);\n    }\n  }\n\n  debug(message?: any, ...params: any[]): void {\n    this.doLog('debug', message, ...params);\n  }\n  info(message?: any, ...params: any[]): void {\n    this.doLog('info', message, ...params);\n  }\n  warn(message?: any, ...params: any[]): void {\n    this.doLog('warn', message, ...params);\n  }\n  error(message?: any, ...params: any[]): void {\n    this.doLog('error', message, ...params);\n  }\n  getLevel(): LogLevelThreshold {\n    return this.level;\n  }\n  setLevel(level: LogLevelThreshold): void {\n    this.level = level;\n  }\n}\n\nexport class ConsoleLogger extends BaseLogger {\n  log(level: LogLevel, msg?: string, ...params: any[]): void {\n    console[level](`[${level}] ${msg}`, ...params);\n  }\n}\n\nexport class NoOpLogger extends BaseLogger {\n  log(_l: LogLevel, _m?: string, ..._p: any[]): void {\n    // do nothing\n  }\n}\n\nexport class Logger {\n  static debug(message?: any, ...params: any[]): void {\n    Logger.defaultLogger.debug(message, ...params);\n  }\n  static info(message?: any, ...params: any[]): void {\n    Logger.defaultLogger.info(message, ...params);\n  }\n  static warn(message?: any, ...params: any[]): void {\n    Logger.defaultLogger.warn(message, ...params);\n  }\n  static error(message?: any, ...params: any[]): void {\n    Logger.defaultLogger.error(message, ...params);\n  }\n  static getLevel(): LogLevelThreshold {\n    return Logger.defaultLogger.getLevel();\n  }\n  static setLevel(level: LogLevelThreshold): void {\n    Logger.defaultLogger.setLevel(level);\n  }\n\n  private static defaultLogger: ILogger = new ConsoleLogger();\n\n  static setDefaultLogger(logger: ILogger) {\n    Logger.defaultLogger = logger;\n  }\n}\n\nexport const withTiming = <T>(\n  fn: () => T,\n  onDidComplete: (elapsed: number) => void\n): T => {\n  const tsStart = Date.now();\n  const res = fn();\n  const tsEnd = Date.now();\n  onDidComplete(tsEnd - tsStart);\n  return res;\n};\n\nexport const withTimingAsync = async <T>(\n  fn: () => Promise<T>,\n  onDidComplete: (elapsed: number) => void\n): Promise<T> => {\n  const tsStart = Date.now();\n  const res = await fn();\n  const tsEnd = Date.now();\n  onDidComplete(tsEnd - tsStart);\n  return res;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/md.test.ts",
    "content": "import { isInFrontMatter, isOnYAMLKeywordLine } from './md';\n\ndescribe('isInFrontMatter', () => {\n  it('is true for started front matter', () => {\n    const content = `---\n\n`;\n    const actual = isInFrontMatter(content, 1);\n    expect(actual).toBeTruthy();\n  });\n  it('is true for inside completed front matter', () => {\n    const content = '---\\ntitle: A title\\n---\\n';\n    const actual = isInFrontMatter(content, 1);\n    expect(actual).toBeTruthy();\n  });\n  it('is true for inside completed front matter with \"...\" end delimiter', () => {\n    const content = '---\\ntitle: A title\\n...\\n';\n    const actual = isInFrontMatter(content, 1);\n    expect(actual).toBeTruthy();\n  });\n  it('is false for non valid front matter delimiter #1347', () => {\n    const content = '---\\ntitle: A title\\n-..\\n\\n\\n---\\ntest\\n';\n    expect(isInFrontMatter(content, 1)).toBeTruthy();\n    expect(isInFrontMatter(content, 4)).toBeTruthy();\n    expect(isInFrontMatter(content, 6)).toBeFalsy();\n  });\n  it('is false for outside completed front matter', () => {\n    const content = '---\\ntitle: A title\\n---\\ncontent\\nmore content\\n';\n    const actual = isInFrontMatter(content, 3);\n    expect(actual).toBeFalsy();\n  });\n  it('is false for outside completed front matter with \"...\" end delimiter', () => {\n    const content = '---\\ntitle: A title\\n...\\ncontent\\nmore content\\n';\n    const actual = isInFrontMatter(content, 3);\n    expect(actual).toBeFalsy();\n  });\n  it('is false for position on initial front matter delimiter', () => {\n    const content = '---\\ntitle: A title\\n---\\ncontent\\nmore content\\n';\n    const actual = isInFrontMatter(content, 0);\n    expect(actual).toBeFalsy();\n  });\n  it('is false for position on final front matter delimiter', () => {\n    const content = '---\\ntitle: A title\\n---\\ncontent\\nmore content\\n';\n    const actual = isInFrontMatter(content, 2);\n    expect(actual).toBeFalsy();\n  });\n\n  describe('isOnYAMLKeywordLine', () => {\n    it('is true if line starts with keyword', () => {\n      const content = 'tags: foo, bar\\n';\n      const actual = isOnYAMLKeywordLine(content, 'tags');\n      expect(actual).toBeTruthy();\n    });\n    it('is true if previous line starts with keyword', () => {\n      const content = 'tags: foo\\n - bar\\n';\n      const actual = isOnYAMLKeywordLine(content, 'tags');\n      expect(actual).toBeTruthy();\n    });\n    it('is false if line starts with wrong keyword', () => {\n      const content = 'tags: foo, bar\\n';\n      const actual = isOnYAMLKeywordLine(content, 'title');\n      expect(actual).toBeFalsy();\n    });\n    it('is false if previous line starts with wrong keyword', () => {\n      const content = 'dates:\\n - 2023-01-1\\n - 2023-01-02\\n';\n      const actual = isOnYAMLKeywordLine(content, 'tags');\n      expect(actual).toBeFalsy();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/md.ts",
    "content": "import matter from 'gray-matter';\n\nexport function getExcerpt(\n  markdown: string,\n  maxLines: number\n): { excerpt: string; lines: number } {\n  const OFFSET_LINES_LIMIT = 5;\n  const paragraphs = markdown.replace(/\\r\\n/g, '\\n').split('\\n\\n');\n  const excerpt: string[] = [];\n  let lines = 0;\n  for (const paragraph of paragraphs) {\n    const n = paragraph.split('\\n').length;\n    if (lines > maxLines || lines + n - maxLines > OFFSET_LINES_LIMIT) {\n      break;\n    }\n    excerpt.push(paragraph);\n    lines = lines + n + 1;\n  }\n  return { excerpt: excerpt.join('\\n\\n'), lines };\n}\n\nexport function stripFrontMatter(markdown: string): string {\n  return matter(markdown).content.trim();\n}\n\nexport function stripImages(markdown: string): string {\n  return markdown.replace(\n    /!\\[(.*)\\]\\([-/\\\\.A-Za-z]*\\)/gi,\n    '$1'.length ? '[Image: $1]' : ''\n  );\n}\n\n/**\n * Returns if the given line is inside a front matter block\n * @param content the string to check\n * @param lineNumber the line number within the string, 0-based\n * @returns true if the line is inside a frontmatter block in content\n */\nexport function isInFrontMatter(content: string, lineNumber: number): boolean {\n  const FIRST_DELIMITER_MATCH = /^---\\s*?$/m;\n  const LAST_DELIMITER_MATCH = /^(-{3}|\\.{3})/;\n\n  // if we're on the first line, we're not _yet_ in the front matter\n  if (lineNumber === 0) {\n    return false;\n  }\n\n  // look for --- at start, and a second --- or ... to end\n  if (content.match(FIRST_DELIMITER_MATCH) === null) {\n    return false;\n  }\n\n  const lines = content.split('\\n');\n  lines.shift();\n  const endLineNumber = lines.findIndex(l => l.match(LAST_DELIMITER_MATCH));\n\n  return endLineNumber === -1 || endLineNumber >= lineNumber;\n}\n\nexport function isOnYAMLKeywordLine(content: string, keyword: string): boolean {\n  const keywordMatch = /^\\s*(\\w+):/gm;\n\n  if (content.match(keywordMatch) === null) {\n    return false;\n  }\n\n  const matches = Array.from(content.matchAll(keywordMatch));\n  const lastMatch = matches[matches.length - 1];\n  return lastMatch[1] === keyword;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/path.test.ts",
    "content": "import { asAbsolutePaths, fromFsPath } from './path';\n\ndescribe('path utils', () => {\n  describe('fromFsPath', () => {\n    it('should normalize backslashes in relative paths', () => {\n      const [path] = fromFsPath('areas\\\\dailies\\\\2024\\\\file.md');\n      expect(path).toBe('areas/dailies/2024/file.md');\n    });\n\n    it('should handle mixed separators in relative paths', () => {\n      const [path] = fromFsPath('areas/dailies\\\\2024/file.md');\n      expect(path).toBe('areas/dailies/2024/file.md');\n    });\n\n    it('should preserve forward slashes in relative paths', () => {\n      const [path] = fromFsPath('areas/dailies/2024/file.md');\n      expect(path).toBe('areas/dailies/2024/file.md');\n    });\n\n    it('should normalize backslashes in Windows absolute paths', () => {\n      const [path] = fromFsPath('C:\\\\workspace\\\\file.md');\n      expect(path).toBe('/C:/workspace/file.md');\n    });\n  });\n\n  describe('asAbsolutePaths', () => {\n    it('returns the path if already absolute', () => {\n      const paths = asAbsolutePaths('/path/to/test', [\n        '/root/Users',\n        '/root/tmp',\n      ]);\n      expect(paths).toEqual(['/path/to/test']);\n    });\n    it('returns the matching base if found', () => {\n      const paths = asAbsolutePaths('tmp/to/test', [\n        '/root/Users',\n        '/root/tmp',\n      ]);\n      expect(paths).toEqual(['/root/tmp/to/test']);\n    });\n    it('returns all bases if no match is found', () => {\n      const paths = asAbsolutePaths('path/to/test', [\n        '/root/Users',\n        '/root/tmp',\n      ]);\n      expect(paths).toEqual([\n        '/root/Users/path/to/test',\n        '/root/tmp/path/to/test',\n      ]);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/path.ts",
    "content": "import { CharCode } from '../common/charCode';\nimport { posix } from 'path';\nimport { isNone } from './core';\n\n/**\n * Converts filesystem path to POSIX path. Supported inputs are:\n *   - Windows path starting with a drive letter, e.g. C:\\dir\\file.ext\n *   - UNC path for a shared file, e.g. \\\\server\\share\\path\\file.ext\n *   - POSIX path, e.g. /dir/file.ext\n *\n * @param path A supported filesystem path.\n * @returns [path, authority] where path is a POSIX representation for the\n *     given input and authority is undefined except for UNC paths.\n */\nexport function fromFsPath(path: string): [string, string] {\n  let authority: string;\n  if (isUNCShare(path)) {\n    [path, authority] = parseUNCShare(path);\n  } else if (hasDrive(path)) {\n    path = '/' + path[0].toUpperCase() + path.substr(1);\n  } else if (path[0] === '/' && hasDrive(path, 1)) {\n    // POSIX representation of a Windows path: just normalize drive letter case\n    path = '/' + path[1].toUpperCase() + path.substr(2);\n  }\n\n  // Always normalize backslashes to forward slashes (filesystem → POSIX)\n  path = path.replace(/\\\\/g, '/');\n\n  return [path, authority];\n}\n\n/**\n * Converts a POSIX path to a filesystem path.\n *\n * @param path A POSIX path.\n * @param authority An optional authority used to build UNC paths. This only\n *     makes sense for the Windows platform.\n * @returns A platform-specific representation of the given POSIX path.\n */\nexport function toFsPath(path: string, authority?: string): string {\n  if (path[0] === '/' && hasDrive(path, 1)) {\n    path = path.substr(1).replace(/\\//g, '\\\\');\n    if (authority) {\n      path = `\\\\\\\\${authority}${path}`;\n    }\n  }\n  return path;\n}\n\n/**\n * Extracts the containing directory of a POSIX path, e.g.\n *    - /d1/d2/f.ext -> /d1/d2\n *    - /d1/d2 -> /d1\n *\n * @param path A POSIX path.\n * @returns true if the path is absolute, false otherwise.\n */\nexport function isAbsolute(path: string): boolean {\n  return posix.isAbsolute(path);\n}\n\n/**\n * Extracts the containing directory of a POSIX path, e.g.\n *    - /d1/d2/f.ext -> /d1/d2\n *    - /d1/d2 -> /d1\n *\n * @param path A POSIX path.\n * @returns The containing directory of the given path.\n */\nexport function getDirectory(path: string): string {\n  return posix.dirname(path);\n}\n\n/**\n * Extracts the basename of a POSIX path, e.g. /d/f.ext -> f.ext.\n *\n * @param path A POSIX path.\n * @returns The basename of the given path.\n */\nexport function getBasename(path: string): string {\n  return posix.basename(path);\n}\n\n/**\n * Extracts the name of a POSIX path, e.g. /d/f.ext -> f.\n *\n * @param path A POSIX path.\n * @returns The name of the given path.\n */\nexport function getName(path: string): string {\n  return changeExtension(getBasename(path), '*', '');\n}\n\n/**\n * Extracts the extension of a POSIX path, e.g.\n *    - /d/f.ext -> .ext\n *    - /d/f.g.ext -> .ext\n *    - /d/f -> ''\n *\n * @param path A POSIX path.\n * @returns The extension of the given path.\n */\nexport function getExtension(path: string): string {\n  return posix.extname(path);\n}\n\n/**\n * Changes a POSIX path matching some extension to have another extension.\n *\n * @param path A POSIX path.\n * @param from The required current extension, or '*' to match any extension.\n * @param to The target extension.\n * @returns A POSIX path with its extension possibly changed.\n */\nexport function changeExtension(\n  path: string,\n  from: string,\n  to: string\n): string {\n  const old = getExtension(path);\n  if ((from === '*' && old !== to) || old === from) {\n    path = path.substring(0, path.length - old.length);\n    return to ? path + to : path;\n  }\n  return path;\n}\n\n/**\n * Joins a number of POSIX paths into a single POSIX path, e.g.\n *    - /d1, d2, f.ext -> /d1/d2/f.ext\n *    - /d1/d2, .., f.ext -> /d1/f.ext\n *\n * @param paths A variable number of POSIX paths.\n * @returns A POSIX path built from the given POSIX paths.\n */\nexport function joinPath(...paths: string[]): string {\n  return posix.join(...paths);\n}\n\n/**\n * Makes a POSIX path relative to another POSIX path, e.g.\n *    - /d1/d2 relative to /d1 -> d2\n *    - /d1/d2 relative to /d1/d3 -> ../d2\n *\n * @param path The POSIX path to be made relative.\n * @param basePath The POSIX base path.\n * @returns A POSIX path relative to the base path.\n */\nexport function relativeTo(path: string, basePath: string): string {\n  return posix.relative(basePath, path);\n}\n\nfunction hasDrive(path: string, idx = 0): boolean {\n  if (path.length <= idx) {\n    return false;\n  }\n  const c = path.charCodeAt(idx);\n  return (\n    ((c >= CharCode.A && c <= CharCode.Z) ||\n      (c >= CharCode.a && c <= CharCode.z)) &&\n    path.charCodeAt(idx + 1) === CharCode.Colon\n  );\n}\n\nfunction isUNCShare(fsPath: string): boolean {\n  return (\n    fsPath.length >= 2 &&\n    fsPath.charCodeAt(0) === CharCode.Backslash &&\n    fsPath.charCodeAt(1) === CharCode.Backslash\n  );\n}\n\nfunction parseUNCShare(uncPath: string): [string, string] {\n  const idx = uncPath.indexOf('\\\\', 2);\n  if (idx === -1) {\n    return [uncPath.substring(2), '\\\\'];\n  } else {\n    return [uncPath.substring(2, idx), uncPath.substring(idx) || '\\\\'];\n  }\n}\n\n/**\n * Turns a relative path into an absolute path given a collection of base folders.\n * - if no base folder is provided, it will throw\n * - if the given path is already absolute, it will return it\n * - if the given path is relative it will return absolute paths for the ones matching the\n *     first part of the path\n * - if no matching base folder is found, it will return an absolute path per base folder\n * @param path the path to evaluate\n * @param baseFolders the base folders to use\n * @returns an array of absolute path, guaranteed to have at least 1 element\n */\nexport function asAbsolutePaths(path: string, baseFolders: string[]): string[] {\n  if (isNone(baseFolders) || baseFolders.length === 0) {\n    throw new Error('Cannot compute absolute URI without a base');\n  }\n\n  if (isAbsolute(path)) {\n    return [path];\n  }\n  let tokens = path.split('/');\n  const firstDir = tokens[0];\n  const res = [];\n  if (baseFolders.length > 1) {\n    for (const folder of baseFolders) {\n      const lastDir = folder.split('/').pop();\n      if (lastDir === firstDir) {\n        tokens = tokens.slice(1);\n        res.push([folder, ...tokens].join('/'));\n        continue;\n      }\n    }\n  }\n  if (res.length === 0) {\n    for (const folder of baseFolders) {\n      const match = folder.endsWith('/')\n        ? folder.substring(0, folder.length - 1)\n        : folder;\n      res.push([match, ...tokens].join('/'));\n    }\n  }\n  return res;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/slug.ts",
    "content": "import slugger from 'github-slugger';\n\nexport const toSlug = (s: string) => slugger.slug(s);\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/task-deduplicator.test.ts",
    "content": "import { TaskDeduplicator } from './task-deduplicator';\n\ndescribe('TaskDeduplicator', () => {\n  describe('run', () => {\n    it('should execute a task and return its result', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task = jest.fn(async () => 'result');\n\n      const result = await deduplicator.run(task);\n\n      expect(result).toBe('result');\n      expect(task).toHaveBeenCalledTimes(1);\n    });\n\n    it('should deduplicate concurrent calls to the same task', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      let executeCount = 0;\n\n      const task = async () => {\n        executeCount++;\n        await new Promise(resolve => setTimeout(resolve, 10));\n        return 'result';\n      };\n\n      // Start multiple concurrent calls\n      const [result1, result2, result3] = await Promise.all([\n        deduplicator.run(task),\n        deduplicator.run(task),\n        deduplicator.run(task),\n      ]);\n\n      // All should get the same result\n      expect(result1).toBe('result');\n      expect(result2).toBe('result');\n      expect(result3).toBe('result');\n\n      // Task should only execute once\n      expect(executeCount).toBe(1);\n    });\n\n    it('should call onDuplicate callback for concurrent calls', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const onDuplicate = jest.fn();\n\n      const task = async () => {\n        await new Promise(resolve => setTimeout(resolve, 10));\n        return 'result';\n      };\n\n      // Start concurrent calls\n      const promise1 = deduplicator.run(task);\n      const promise2 = deduplicator.run(task, onDuplicate);\n      const promise3 = deduplicator.run(task, onDuplicate);\n\n      await Promise.all([promise1, promise2, promise3]);\n\n      // onDuplicate should be called for the 2nd and 3rd calls\n      expect(onDuplicate).toHaveBeenCalledTimes(2);\n    });\n\n    it('should not call onDuplicate for the first call', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const onDuplicate = jest.fn();\n      const task = jest.fn(async () => 'result');\n\n      await deduplicator.run(task, onDuplicate);\n\n      expect(onDuplicate).not.toHaveBeenCalled();\n    });\n\n    it('should allow new tasks after previous task completes', async () => {\n      const deduplicator = new TaskDeduplicator<number>();\n      let counter = 0;\n\n      const task1 = async () => ++counter;\n      const task2 = async () => ++counter;\n\n      const result1 = await deduplicator.run(task1);\n      const result2 = await deduplicator.run(task2);\n\n      expect(result1).toBe(1);\n      expect(result2).toBe(2);\n    });\n\n    it('should propagate errors from the task', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const error = new Error('Task failed');\n      const task = jest.fn(async () => {\n        throw error;\n      });\n\n      await expect(deduplicator.run(task)).rejects.toThrow('Task failed');\n    });\n\n    it('should propagate errors to all concurrent callers', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const error = new Error('Task failed');\n\n      const task = async () => {\n        await new Promise(resolve => setTimeout(resolve, 10));\n        throw error;\n      };\n\n      const promise1 = deduplicator.run(task);\n      const promise2 = deduplicator.run(task);\n      const promise3 = deduplicator.run(task);\n\n      await expect(promise1).rejects.toThrow('Task failed');\n      await expect(promise2).rejects.toThrow('Task failed');\n      await expect(promise3).rejects.toThrow('Task failed');\n    });\n\n    it('should clear running task after error', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task1 = jest.fn(async () => {\n        throw new Error('Task failed');\n      });\n      const task2 = jest.fn(async () => 'success');\n\n      // First task fails\n      await expect(deduplicator.run(task1)).rejects.toThrow('Task failed');\n\n      // Second task should execute (not deduplicated)\n      const result = await deduplicator.run(task2);\n\n      expect(result).toBe('success');\n      expect(task1).toHaveBeenCalledTimes(1);\n      expect(task2).toHaveBeenCalledTimes(1);\n    });\n\n    it('should handle different return types', async () => {\n      // String\n      const stringDeduplicator = new TaskDeduplicator<string>();\n      const stringResult = await stringDeduplicator.run(async () => 'test');\n      expect(stringResult).toBe('test');\n\n      // Number\n      const numberDeduplicator = new TaskDeduplicator<number>();\n      const numberResult = await numberDeduplicator.run(async () => 42);\n      expect(numberResult).toBe(42);\n\n      // Object\n      const objectDeduplicator = new TaskDeduplicator<{ value: string }>();\n      const objectResult = await objectDeduplicator.run(async () => ({\n        value: 'test',\n      }));\n      expect(objectResult).toEqual({ value: 'test' });\n\n      // Union types\n      type Status = 'complete' | 'cancelled' | 'error';\n      const statusDeduplicator = new TaskDeduplicator<Status>();\n      const statusResult = await statusDeduplicator.run(\n        async () => 'complete' as Status\n      );\n      expect(statusResult).toBe('complete');\n    });\n  });\n\n  describe('isRunning', () => {\n    it('should return false when no task is running', () => {\n      const deduplicator = new TaskDeduplicator<string>();\n\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n\n    it('should return true when a task is running', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n\n      const task = async () => {\n        await new Promise(resolve => setTimeout(resolve, 10));\n        return 'result';\n      };\n\n      const promise = deduplicator.run(task);\n\n      expect(deduplicator.isRunning()).toBe(true);\n\n      await promise;\n    });\n\n    it('should return false after task completes', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task = jest.fn(async () => 'result');\n\n      await deduplicator.run(task);\n\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n\n    it('should return false after task fails', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task = jest.fn(async () => {\n        throw new Error('Failed');\n      });\n\n      await expect(deduplicator.run(task)).rejects.toThrow('Failed');\n\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n  });\n\n  describe('clear', () => {\n    it('should clear the running task reference', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n\n      const task = async () => {\n        await new Promise(resolve => setTimeout(resolve, 10));\n        return 'result';\n      };\n\n      const promise = deduplicator.run(task);\n\n      expect(deduplicator.isRunning()).toBe(true);\n\n      deduplicator.clear();\n\n      expect(deduplicator.isRunning()).toBe(false);\n\n      // Original promise should still complete\n      await expect(promise).resolves.toBe('result');\n    });\n\n    it('should allow new task after manual clear', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      let executeCount = 0;\n\n      const task = async () => {\n        executeCount++;\n        await new Promise(resolve => setTimeout(resolve, 50));\n        return 'result';\n      };\n\n      // Start first task\n      const promise1 = deduplicator.run(task);\n\n      // Clear while still running\n      deduplicator.clear();\n\n      // Start second task (should not be deduplicated)\n      const promise2 = deduplicator.run(task);\n\n      await Promise.all([promise1, promise2]);\n\n      // Both tasks should have executed\n      expect(executeCount).toBe(2);\n    });\n\n    it('should be safe to call when no task is running', () => {\n      const deduplicator = new TaskDeduplicator<string>();\n\n      expect(() => deduplicator.clear()).not.toThrow();\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n  });\n\n  describe('edge cases', () => {\n    it('should handle tasks that resolve immediately', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task = jest.fn(async () => 'immediate');\n\n      const result = await deduplicator.run(task);\n\n      expect(result).toBe('immediate');\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n\n    it('should handle tasks that throw synchronously', async () => {\n      const deduplicator = new TaskDeduplicator<string>();\n      const task = jest.fn(() => {\n        throw new Error('Sync error');\n      });\n\n      await expect(deduplicator.run(task as any)).rejects.toThrow('Sync error');\n      expect(deduplicator.isRunning()).toBe(false);\n    });\n\n    it('should handle null/undefined results', async () => {\n      const nullDeduplicator = new TaskDeduplicator<null>();\n      const nullResult = await nullDeduplicator.run(async () => null);\n      expect(nullResult).toBeNull();\n\n      const undefinedDeduplicator = new TaskDeduplicator<undefined>();\n      const undefinedResult = await undefinedDeduplicator.run(\n        async () => undefined\n      );\n      expect(undefinedResult).toBeUndefined();\n    });\n\n    it('should handle sequential calls with delays between them', async () => {\n      const deduplicator = new TaskDeduplicator<number>();\n      let counter = 0;\n\n      const task = async () => {\n        await new Promise(resolve => setTimeout(resolve, 10));\n        return ++counter;\n      };\n\n      const result1 = await deduplicator.run(task);\n      await new Promise(resolve => setTimeout(resolve, 20));\n      const result2 = await deduplicator.run(task);\n\n      expect(result1).toBe(1);\n      expect(result2).toBe(2);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/task-deduplicator.ts",
    "content": "/**\n * A utility class for deduplicating concurrent async operations.\n * When multiple calls are made while a task is running, subsequent calls\n * will wait for and receive the result of the already-running task instead\n * of starting a new one.\n *\n * @example\n * const deduplicator = new TaskDeduplicator<string>();\n *\n * async function expensiveOperation(input: string): Promise<string> {\n *   return deduplicator.run(async () => {\n *     // Expensive work here\n *     return result;\n *   });\n * }\n *\n * // Multiple concurrent calls will share the same execution\n * const [result1, result2] = await Promise.all([\n *   expensiveOperation(\"test\"),\n *   expensiveOperation(\"test\"),\n * ]);\n * // Only runs once, both get the same result\n */\nexport class TaskDeduplicator<T> {\n  private runningTask: Promise<T> | null = null;\n\n  /**\n   * Run a task with deduplication.\n   * If a task is already running, waits for it to complete and returns its result.\n   * Otherwise, starts the task and stores its promise for other callers to await.\n   *\n   * @param task The async function to execute\n   * @param onDuplicate Optional callback when a duplicate call is detected\n   * @returns The result of the task\n   */\n  async run(task: () => Promise<T>, onDuplicate?: () => void): Promise<T> {\n    // If already running, wait for the existing task\n    if (this.runningTask) {\n      onDuplicate?.();\n      return await this.runningTask;\n    }\n\n    // Start the task and store the promise\n    this.runningTask = task();\n\n    try {\n      return await this.runningTask;\n    } finally {\n      // Clear the task when done\n      this.runningTask = null;\n    }\n  }\n\n  /**\n   * Check if a task is currently running\n   */\n  isRunning(): boolean {\n    return this.runningTask !== null;\n  }\n\n  /**\n   * Clear the running task reference (useful for testing or error recovery)\n   */\n  clear(): void {\n    this.runningTask = null;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/core/utils/utils.test.ts",
    "content": "import { extractHashtags } from './index';\nimport { Logger } from './log';\n\nLogger.setLevel('error');\n\ndescribe('hashtag extraction', () => {\n  it('returns empty list if no tags are present', () => {\n    expect(extractHashtags('hello world')).toEqual([]);\n  });\n\n  it('works with simple strings', () => {\n    expect(\n      extractHashtags('hello #world on #this planet').map(t => t.label)\n    ).toEqual(['world', 'this']);\n  });\n\n  it('detects the offset of the tag', () => {\n    expect(extractHashtags('#hello')).toEqual([{ label: 'hello', offset: 0 }]);\n    expect(extractHashtags(' #hello')).toEqual([{ label: 'hello', offset: 1 }]);\n    expect(extractHashtags('to #hello')).toEqual([\n      { label: 'hello', offset: 3 },\n    ]);\n  });\n\n  it('works with tags at beginning or end of text', () => {\n    expect(\n      extractHashtags('#hello world on this #planet').map(t => t.label)\n    ).toEqual(['hello', 'planet']);\n  });\n\n  it('supports _ and -', () => {\n    expect(\n      extractHashtags('#hello-world on #this_planet').map(t => t.label)\n    ).toEqual(['hello-world', 'this_planet']);\n  });\n\n  it('supports nested tags', () => {\n    expect(\n      extractHashtags('#parent/child on #planet').map(t => t.label)\n    ).toEqual(['parent/child', 'planet']);\n  });\n\n  it('ignores tags that only have numbers in text', () => {\n    expect(\n      extractHashtags('this #123 tag should be ignore, but not #123four').map(\n        t => t.label\n      )\n    ).toEqual(['123four']);\n  });\n\n  it('supports unicode letters like Chinese characters', () => {\n    expect(\n      extractHashtags(`\n        this #tag_with_unicode_letters_汉字, pure Chinese tag like #纯中文标签 and \n        other mixed tags like #标签1 #123四 should work\n      `).map(t => t.label)\n    ).toEqual([\n      'tag_with_unicode_letters_汉字',\n      '纯中文标签',\n      '标签1',\n      '123四',\n    ]);\n  });\n\n  it('supports emoji tags', () => {\n    expect(\n      extractHashtags(`this is a pure emoji #⭐, #⭐⭐, #👍👍🏽👍🏿 some mixed emoji #π🥧, #✅todo\n       #urgent❗ or #❗❗urgent, and some nested emoji #📥/🟥 or #📥/🟢\n      `).map(t => t.label)\n    ).toEqual([\n      '⭐',\n      '⭐⭐',\n      '👍👍🏽👍🏿',\n      'π🥧',\n      '✅todo',\n      'urgent❗',\n      '❗❗urgent',\n      '📥/🟥',\n      '📥/🟢',\n    ]);\n  });\n\n  it('supports emoji tags with variant selectors (issue #1536)', () => {\n    expect(\n      extractHashtags('#🗃️/37-Education #🔖/37/Learning #🟣HOUSE #🟠MONEY').map(\n        t => t.label\n      )\n    ).toEqual(['🗃️/37-Education', '🔖/37/Learning', '🟣HOUSE', '🟠MONEY']);\n  });\n\n  it('supports individual emojis with variant selectors', () => {\n    // Test each emoji separately to debug\n    expect(extractHashtags('#🗃️').map(t => t.label)).toEqual(['🗃️']);\n    expect(extractHashtags('#🔖').map(t => t.label)).toEqual(['🔖']);\n  });\n\n  it('supports emojis that work without variant selector', () => {\n    // These emojis should work with current implementation\n    expect(extractHashtags('#📥 #⭐').map(t => t.label)).toEqual(['📥', '⭐']);\n  });\n\n  it('ignores hashes in plain text urls and links', () => {\n    expect(\n      extractHashtags(`\n        test text with url https://site.com/#section1 https://site.com/home#section2 and\n        https://site.com/home/#section3a\n        [link](https://site.com/#section4) with [link2](https://site.com/home#section5) #control\n        hello world\n      `).map(t => t.label)\n    ).toEqual(['control']);\n  });\n\n  it('ignores hashes in links to sections', () => {\n    expect(\n      extractHashtags(`\n      this is a wikilink to [[#section1]] in the file and a [[link#section2]] in another\n      this is a [link](#section3) to a section\n      `)\n    ).toEqual([]);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/dated-notes.spec.ts",
    "content": "/* @unit-ready */\nimport { workspace, window } from 'vscode';\nimport {\n  CREATE_DAILY_NOTE_WARNING_RESPONSE,\n  createDailyNoteIfNotExists,\n  getDailyNoteUri,\n} from './dated-notes';\nimport { isWindows } from './core/common/platform';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  deleteFile,\n  makeFoamMock,\n  showInEditor,\n  withModifiedFoamConfiguration,\n} from './test/test-utils-vscode';\nimport { fromVsCodeUri } from './utils/vsc-utils';\nimport { fileExists, readFile } from './services/editor';\nimport {\n  getDailyNoteTemplateCandidateUris,\n  getDailyNoteTemplateUri,\n} from './services/templates';\n\ndescribe('getDailyNoteUri', () => {\n  const date = new Date('2021-02-07T00:00:00Z');\n  const year = date.getFullYear();\n  const month = date.getMonth() + 1;\n  const day = date.getDate();\n  const isoDate = `${year}-0${month}-0${day}`;\n\n  test('Adds the root directory to relative directories', async () => {\n    const config = 'journal';\n\n    const expectedUri = fromVsCodeUri(\n      workspace.workspaceFolders[0].uri\n    ).joinPath(config, `${isoDate}.md`);\n\n    await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>\n      expect(getDailyNoteUri(date)).toEqual(expectedUri)\n    );\n  });\n\n  test('Uses absolute directories without modification', async () => {\n    const config = isWindows\n      ? 'C:\\\\absolute_path\\\\journal'\n      : '/absolute_path/journal';\n    const expectedPath = isWindows\n      ? `${config}\\\\${isoDate}.md`\n      : `${config}/${isoDate}.md`;\n\n    await withModifiedFoamConfiguration('openDailyNote.directory', config, () =>\n      expect(getDailyNoteUri(date).toFsPath()).toMatch(expectedPath)\n    );\n  });\n});\n\ndescribe('Daily note creation and template processing', () => {\n  const DAILY_NOTE_TEMPLATE = ['.foam', 'templates', 'daily-note.md'];\n\n  beforeEach(async () => {\n    // Ensure daily note template are removed before each test\n    for (const template of getDailyNoteTemplateCandidateUris()) {\n      if (await fileExists(template)) {\n        await deleteFile(template);\n      }\n    }\n  });\n\n  describe('Basic daily note creation', () => {\n    it('Creates a new daily note when it does not exist', async () => {\n      const targetDate = new Date(2021, 8, 1);\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      expect(result.didCreateFile).toBe(true);\n      expect(result.uri).toEqual(uri);\n\n      const doc = await showInEditor(uri);\n      expect(doc.editor.document.getText()).toContain('2021-09-01');\n    });\n\n    it('Opens existing daily note when it already exists', async () => {\n      const targetDate = new Date(2021, 8, 2);\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n\n      // Create the file first\n      await createFile('# Existing Note\\n\\nContent here', [uri.getBasename()]);\n\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      expect(result.didCreateFile).toBe(false);\n      expect(result.uri).toEqual(uri);\n\n      const doc = await showInEditor(uri);\n      expect(doc.editor.document.getText()).toContain('Existing Note');\n    });\n  });\n\n  describe('Template variable resolution', () => {\n    it('Resolves all FOAM_DATE_* variables correctly', async () => {\n      const targetDate = new Date(2021, 8, 12); // September 12, 2021\n\n      const template = await createFile(\n        `# \\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE}\n\nYear: \\${FOAM_DATE_YEAR} (short: \\${FOAM_DATE_YEAR_SHORT})\nMonth: \\${FOAM_DATE_MONTH} (name: \\${FOAM_DATE_MONTH_NAME}, short: \\${FOAM_DATE_MONTH_NAME_SHORT})\nDate: \\${FOAM_DATE_DATE}\nDay: \\${FOAM_DATE_DAY_NAME} (short: \\${FOAM_DATE_DAY_NAME_SHORT})\nWeek: \\${FOAM_DATE_WEEK}\nWeek Year: \\${FOAM_DATE_WEEK_YEAR}\nUnix: \\${FOAM_DATE_SECONDS_UNIX}`,\n        DAILY_NOTE_TEMPLATE\n      );\n\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(result.uri);\n      const content = doc.editor.document.getText();\n\n      expect(content).toContain('# 2021-09-12');\n      expect(content).toContain('Year: 2021 (short: 21)');\n      expect(content).toContain('Month: 09 (name: September, short: Sep)');\n      expect(content).toContain('Date: 12');\n      expect(content).toContain('Day: Sunday (short: Sun)');\n      expect(content).toContain('Week: 36');\n      expect(content).toContain('Week Year: 2021');\n\n      await deleteFile(template.uri);\n      await deleteFile(result.uri);\n    });\n\n    it('Resolves FOAM_TITLE variable for daily notes', async () => {\n      const targetDate = new Date(2021, 8, 13);\n\n      const template = await createFile(\n        // eslint-disable-next-line no-template-curly-in-string\n        '# Daily Note: ${FOAM_TITLE}\\n\\nToday is ${FOAM_TITLE}.',\n        DAILY_NOTE_TEMPLATE\n      );\n\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(uri);\n      const content = doc.editor.document.getText();\n      expect(content).toContain('Daily Note: 2021-09-13');\n      expect(content).toContain('Today is 2021-09-13.');\n      await deleteFile(result.uri);\n      await deleteFile(template.uri);\n    });\n  });\n\n  describe('Configuration settings', () => {\n    it('Respects custom filename format', async () => {\n      const targetDate = new Date(2021, 8, 14);\n      const customFormat = 'yyyy-mm-dd';\n\n      await withModifiedFoamConfiguration(\n        'openDailyNote.filenameFormat',\n        customFormat,\n        async () => {\n          const uri = getDailyNoteUri(targetDate);\n          expect(uri.getBasename()).toBe('2021-09-14.md');\n        }\n      );\n    });\n\n    it('Respects custom file extension', async () => {\n      const targetDate = new Date(2021, 8, 15);\n\n      await withModifiedFoamConfiguration(\n        'openDailyNote.fileExtension',\n        'txt',\n        async () => {\n          const uri = getDailyNoteUri(targetDate);\n          expect(uri.getBasename()).toBe('2021-09-15.txt');\n        }\n      );\n    });\n\n    it('Respects custom directory setting', async () => {\n      const targetDate = new Date(2021, 8, 16);\n      const customDir = 'journal/daily';\n\n      await withModifiedFoamConfiguration(\n        'openDailyNote.directory',\n        customDir,\n        async () => {\n          const uri = getDailyNoteUri(targetDate);\n          expect(uri.path).toContain('/journal/daily/');\n        }\n      );\n    });\n\n    it('Uses custom title format when specified', async () => {\n      const targetDate = new Date(2021, 8, 17);\n\n      await withModifiedFoamConfiguration(\n        'openDailyNote.titleFormat',\n        'fullDate',\n        async () => {\n          const uri = getDailyNoteUri(targetDate);\n          const foam = makeFoamMock();\n          const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n          const doc = await showInEditor(uri);\n          const content = doc.editor.document.getText();\n          expect(content).toContain('# Friday, September 17, 2021');\n          await deleteFile(result.uri);\n        }\n      );\n    });\n  });\n\n  describe('Template types and processing', () => {\n    it('Processes Markdown templates correctly', async () => {\n      const targetDate = new Date(2021, 8, 19);\n\n      const template = await createFile(\n        // eslint-disable-next-line no-template-curly-in-string\n        'hello ${FOAM_DATE_MONTH_NAME} ${FOAM_DATE_DATE} hello',\n        DAILY_NOTE_TEMPLATE\n      );\n\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(uri);\n      const content = doc.editor.document.getText();\n      expect(content).toEqual('hello September 19 hello');\n      await deleteFile(result.uri);\n      await deleteFile(template.uri);\n    });\n\n    it('Processes JavaScript templates correctly', async () => {\n      const targetDate = new Date(2021, 8, 20);\n\n      const jsTemplate = await createFile(\n        `async function createNote ({ foamDate }) {\n  const monthName = foamDate.toLocaleString('default', { month: 'long' });\n  const day = foamDate.getDate();\n  return {\n    filepath: \\`\\${foamDate.getFullYear()}-\\${String(foamDate.getMonth() + 1).padStart(2, '0')}-\\${String(day).padStart(2, '0')}.md\\`,\n    content: \\`# JS Template: \\${monthName} \\${day}\\n\\nGenerated by JavaScript template.\\`\n  };\n};`,\n        ['.foam', 'templates', 'daily-note.js']\n      );\n\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(uri);\n      const content = doc.editor.document.getText();\n      expect(content).toContain('# JS Template: September 20');\n      expect(content).toContain('Generated by JavaScript template.');\n\n      await deleteFile(jsTemplate.uri);\n      await deleteFile(result.uri);\n    });\n\n    it('Falls back to default text when no template exists', async () => {\n      const targetDate = new Date(2021, 8, 21);\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(result.uri);\n      const content = doc.editor.document.getText();\n      expect(content).toContain('# 2021-09-21'); // Should use fallback text with formatted date\n    });\n\n    it('prompts to create a daily note template if one does not exist', async () => {\n      const targetDate = new Date(2021, 8, 23);\n      const foam = makeFoamMock();\n\n      expect(await getDailyNoteTemplateUri()).not.toBeDefined();\n\n      // Intercept the showWarningMessage call\n      const showWarningMessageSpy = jest\n        .spyOn(window, 'showWarningMessage')\n        .mockResolvedValue(CREATE_DAILY_NOTE_WARNING_RESPONSE as any); // simulate user action\n\n      await createDailyNoteIfNotExists(targetDate, foam);\n\n      expect(showWarningMessageSpy.mock.calls[0][0]).toMatch(\n        /No daily note template found/\n      );\n\n      const templateUri = await getDailyNoteTemplateUri();\n\n      expect(templateUri).toBeDefined();\n      expect(await fileExists(templateUri)).toBe(true);\n\n      const templateContent = await readFile(templateUri);\n      expect(templateContent).toContain('foam_template:');\n\n      // Clean up the created template\n      await deleteFile(templateUri);\n      showWarningMessageSpy.mockRestore();\n    });\n\n    it('Processes template frontmatter metadata correctly', async () => {\n      const targetDate = new Date(2021, 8, 22);\n\n      const template = await createFile(\n        `---\ntags: [daily, journal]\nauthor: foam\n---\n# Daily Note\n\nContent here with \\${FOAM_DATE_MONTH_NAME} \\${FOAM_DATE_DATE}`,\n        DAILY_NOTE_TEMPLATE\n      );\n\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n      const result = await createDailyNoteIfNotExists(targetDate, foam);\n\n      const doc = await showInEditor(uri);\n      const content = doc.editor.document.getText();\n\n      // Should not contain the frontmatter separator in final content\n      expect(content).toContain(`---\ntags: [daily, journal]\nauthor: foam\n---`);\n      expect(content).toContain('# Daily Note');\n      expect(content).toContain('Content here with September 22');\n\n      await deleteFile(template.uri);\n      await deleteFile(result.uri);\n    });\n  });\n\n  describe('Issue #1499 - Double template application with absolute paths', () => {\n    it('should not apply template twice when reopening existing daily note with absolute filepath template', async () => {\n      const targetDate = new Date(2021, 8, 25);\n      const TEMPLATE_WITH_ABSOLUTE_FILEPATH = `---\nfoam_template:\n  name: Daily note\n  description: Daily note template\n  filepath: '/\\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE}.md'\n---\n\n# \\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE} - DAILY NOTE\n\nDaily content here.`;\n\n      // Create the template with absolute filepath\n      const template = await createFile(\n        TEMPLATE_WITH_ABSOLUTE_FILEPATH,\n        DAILY_NOTE_TEMPLATE\n      );\n\n      const uri = getDailyNoteUri(targetDate);\n      const foam = makeFoamMock();\n\n      // First call: Create the daily note\n      const result1 = await createDailyNoteIfNotExists(targetDate, foam);\n      expect(result1.didCreateFile).toBe(true);\n\n      const doc1 = await showInEditor(uri);\n      const content1 = doc1.editor.document.getText();\n      expect(content1).toContain('# 2021-09-25 - DAILY NOTE');\n      expect(content1).toContain('Daily content here.');\n\n      // Count how many times the template content appears (should be once)\n      const templateOccurrences1 = (\n        content1.match(/# 2021-09-25 - DAILY NOTE/g) || []\n      ).length;\n      expect(templateOccurrences1).toBe(1);\n\n      await closeEditors();\n\n      // Second call: Open existing daily note (this should NOT apply template again)\n      const result2 = await createDailyNoteIfNotExists(targetDate, foam);\n      expect(result2.didCreateFile).toBe(false); // File already exists\n\n      const doc2 = await showInEditor(uri);\n      const content2 = doc2.editor.document.getText();\n\n      // Verify template is NOT applied twice\n      const templateOccurrences2 = (\n        content2.match(/# 2021-09-25 - DAILY NOTE/g) || []\n      ).length;\n      expect(templateOccurrences2).toBe(1); // Should still be 1, not 2\n\n      // Content should be identical to first time\n      expect(content2).toEqual(content1);\n\n      await deleteFile(template.uri);\n      await deleteFile(result1.uri);\n    });\n  });\n\n  afterAll(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/dated-notes.ts",
    "content": "import { Uri, window, workspace } from 'vscode';\nimport { joinPath } from './core/utils/path';\nimport dateFormat from 'dateformat';\nimport { URI } from './core/model/uri';\nimport { getDailyNoteTemplateUri } from './services/templates';\nimport { getFoamVsCodeConfig } from './services/config';\nimport { asAbsoluteWorkspaceUri, focusNote } from './services/editor';\nimport { Foam } from './core/model/foam';\nimport { createNote } from './features/commands/create-note';\nimport { fromVsCodeUri } from './utils/vsc-utils';\nimport { showInEditor } from './test/test-utils-vscode';\n\n/**\n * Open the daily note file.\n *\n * In the case that the daily note file does not exist,\n * it gets created along with any folders in its path.\n *\n * @param date The target date. If not provided, the function returns immediately.\n * @param foam The Foam instance, used to create the note.\n */\nexport async function openDailyNoteFor(date?: Date, foam?: Foam) {\n  if (date == null) {\n    return;\n  }\n\n  const { didCreateFile, uri } = await createDailyNoteIfNotExists(date, foam);\n  // if a new file is created, the editor is automatically created\n  // but forcing the focus will block the template placeholders from working\n  // so we only explicitly focus on the note if the file already exists\n  if (!didCreateFile) {\n    await focusNote(uri, didCreateFile);\n  }\n}\n\n/**\n * Get the daily note file path.\n *\n * This function first checks the `foam.openDailyNote.directory` configuration string,\n * defaulting to the current directory.\n *\n * @param date A given date to be formatted as filename.\n * @returns The URI to the daily note file.\n */\nexport function getDailyNoteUri(date: Date): URI {\n  const folder = getFoamVsCodeConfig<string>('openDailyNote.directory') ?? '.';\n  const dailyNoteFilename = getDailyNoteFileName(date);\n  return asAbsoluteWorkspaceUri(joinPath(folder, dailyNoteFilename));\n}\n\n/**\n * Get the daily note filename (basename) to use.\n *\n * Fetch the filename format and extension from\n * `foam.openDailyNote.filenameFormat` and\n * `foam.openDailyNote.fileExtension`, respectively.\n *\n * @param date A given date to be formatted as filename.\n * @returns The daily note's filename.\n */\nexport function getDailyNoteFileName(date: Date): string {\n  const filenameFormat: string = getFoamVsCodeConfig(\n    'openDailyNote.filenameFormat',\n    'yyyy-mm-dd'\n  );\n  const fileExtension: string = getFoamVsCodeConfig(\n    'openDailyNote.fileExtension',\n    'md'\n  );\n\n  return `${dateFormat(date, filenameFormat, false)}.${fileExtension}`;\n}\n\nconst DEFAULT_DAILY_NOTE_TEMPLATE = `---\nfoam_template:\n  filepath: \"/journal/\\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE}.md\"\n  description: \"Daily note template\"\n---\n# \\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE}\n\n> you probably want to delete these instructions as you customize your template\n\nWelcome to your new daily note template.\nThe file is located in \\`.foam/templates/daily-note.md\\`.\nThe text in this file will be used as the content of your daily note.\nYou can customize it as you like, and you can use the following variables in the template:\n- \\`\\${FOAM_DATE_YEAR}\\`: The year of the date\n- \\`\\${FOAM_DATE_MONTH}\\`: The month of the date\n- \\`\\${FOAM_DATE_DATE}\\`: The day of the date\n- \\`\\${FOAM_TITLE}\\`: The title of the note\n\nGo to https://github.com/foambubble/foam/blob/main/docs/user/features/daily-notes.md for more details.\nFor more complex templates, including Javascript dynamic templates, see https://github.com/foambubble/foam/blob/main/docs/user/features/templates.md.\n`;\n\nexport const CREATE_DAILY_NOTE_WARNING_RESPONSE = 'Create daily note template';\n\n/**\n * Create a daily note using the unified creation engine (supports JS templates)\n *\n * @param targetDate The target date\n * @param foam The Foam instance\n * @returns Whether the file was created and the URI\n */\nexport async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) {\n  const templatePath = await getDailyNoteTemplateUri();\n\n  if (!templatePath) {\n    window\n      .showWarningMessage(\n        'No daily note template found. Using legacy configuration (deprecated). Create a daily note template to avoid this warning and customize your daily note.',\n        CREATE_DAILY_NOTE_WARNING_RESPONSE\n      )\n      .then(async action => {\n        if (action === CREATE_DAILY_NOTE_WARNING_RESPONSE) {\n          const newTemplateUri = Uri.joinPath(\n            workspace.workspaceFolders[0].uri,\n            '.foam',\n            'templates',\n            'daily-note.md'\n          );\n          await workspace.fs.writeFile(\n            newTemplateUri,\n            new TextEncoder().encode(DEFAULT_DAILY_NOTE_TEMPLATE)\n          );\n          await showInEditor(fromVsCodeUri(newTemplateUri));\n        }\n      });\n  }\n\n  // Set up variables for template processing\n  const formattedDate = dateFormat(targetDate, 'yyyy-mm-dd', false);\n  const variables = {\n    FOAM_TITLE: formattedDate,\n    title: formattedDate,\n  };\n\n  const dailyNoteUri = getDailyNoteUri(targetDate);\n  const titleFormat: string =\n    getFoamVsCodeConfig('openDailyNote.titleFormat') ??\n    getFoamVsCodeConfig('openDailyNote.filenameFormat') ??\n    'isoDate';\n\n  const templateFallbackText = `# ${dateFormat(\n    targetDate,\n    titleFormat,\n    false\n  )}\\n`;\n\n  return await createNote(\n    {\n      notePath: dailyNoteUri.toFsPath(),\n      templatePath: templatePath,\n      text: templateFallbackText,\n      date: targetDate,\n      variables: variables,\n      onFileExists: 'open',\n      onRelativeNotePath: 'resolve-from-root',\n    },\n    foam\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/extension.ts",
    "content": "/*global markdownit:readonly*/\n\nimport { workspace, ExtensionContext, window, commands } from 'vscode';\nimport { MarkdownResourceProvider } from './core/services/markdown-provider';\nimport { bootstrap } from './core/model/foam';\nimport { Logger } from './core/utils/log';\nimport { fromVsCodeUri } from './utils/vsc-utils';\n\nimport { features } from './features';\nimport { VsCodeOutputLogger, exposeLogger } from './services/logging';\nimport {\n  getAttachmentsExtensions,\n  getDirectoryModeSetting,\n  getExcludedFilesSetting,\n  getIncludeFilesSetting,\n  getNotesExtensions,\n} from './settings';\nimport { AttachmentResourceProvider } from './core/services/attachment-provider';\nimport { VsCodeWatcher } from './services/watcher';\nimport { createMarkdownParser } from './core/services/markdown-parser';\nimport VsCodeBasedParserCache from './services/cache';\nimport { createMatcherAndDataStore } from './services/editor';\nimport { OllamaEmbeddingProvider } from './ai/providers/ollama/ollama-provider';\n\nexport async function activate(context: ExtensionContext) {\n  const logger = new VsCodeOutputLogger();\n  Logger.setDefaultLogger(logger);\n  exposeLogger(context, logger);\n\n  try {\n    Logger.info('Starting Foam');\n\n    if (workspace.workspaceFolders === undefined) {\n      Logger.info('No workspace open. Foam will not start');\n      return;\n    }\n\n    // Prepare Foam\n    const includes = getIncludeFilesSetting().map(g => g.toString());\n    const excludes = getExcludedFilesSetting().map(g => g.toString());\n    const { matcher, dataStore, includePatterns, excludePatterns } =\n      await createMatcherAndDataStore(includes, excludes);\n\n    Logger.info('Loading from directories:');\n    for (const folder of workspace.workspaceFolders) {\n      Logger.info('- ' + folder.uri.fsPath);\n      Logger.info('  Include: ' + includePatterns.get(folder.name).join(','));\n      Logger.info('  Exclude: ' + excludePatterns.get(folder.name).join(','));\n    }\n\n    const watcher = new VsCodeWatcher(\n      workspace.createFileSystemWatcher('**/*')\n    );\n    const parserCache = new VsCodeBasedParserCache(context);\n    const parser = createMarkdownParser([], parserCache);\n\n    const { notesExtensions, defaultExtension } = getNotesExtensions();\n\n    const workspaceRoots =\n      workspace.workspaceFolders?.map(folder => fromVsCodeUri(folder.uri)) ??\n      [];\n\n    const directoryMode = getDirectoryModeSetting();\n    const markdownProvider = new MarkdownResourceProvider(\n      dataStore,\n      parser,\n      notesExtensions,\n      directoryMode\n    );\n\n    const attachmentExtConfig = getAttachmentsExtensions();\n    const attachmentProvider = new AttachmentResourceProvider(\n      attachmentExtConfig\n    );\n\n    // Initialize embedding provider\n    const aiEnabled = workspace.getConfiguration('foam.experimental').get('ai');\n    const embeddingProvider = aiEnabled\n      ? new OllamaEmbeddingProvider()\n      : undefined;\n\n    const foamPromise = bootstrap(\n      workspaceRoots,\n      matcher,\n      watcher,\n      dataStore,\n      parser,\n      [markdownProvider, attachmentProvider],\n      defaultExtension,\n      embeddingProvider\n    );\n\n    // Load the features\n    const featuresPromises = features.map(feature =>\n      feature(context, foamPromise)\n    );\n\n    const foam = await foamPromise;\n    Logger.info(`Loaded ${foam.workspace.list().length} resources`);\n\n    context.subscriptions.push(\n      foam,\n      watcher,\n      markdownProvider,\n      attachmentProvider,\n      commands.registerCommand('foam-vscode.clear-cache', () =>\n        parserCache.clear()\n      ),\n      workspace.onDidChangeConfiguration(e => {\n        if (\n          [\n            'foam.files.ignore',\n            'foam.files.exclude',\n            'foam.files.include',\n            'foam.files.attachmentExtensions',\n            'foam.files.noteExtensions',\n            'foam.files.defaultNoteExtension',\n          ].some(setting => e.affectsConfiguration(setting))\n        ) {\n          window.showInformationMessage(\n            'Foam: Reload the window to use the updated settings'\n          );\n        }\n      })\n    );\n\n    const feats = (await Promise.all(featuresPromises)).filter(r => r != null);\n\n    return {\n      extendMarkdownIt: (md: markdownit) => {\n        return feats.reduce((acc: markdownit, r: any) => {\n          return r.extendMarkdownIt ? r.extendMarkdownIt(acc) : acc;\n        }, md);\n      },\n      foam,\n    };\n  } catch (e) {\n    Logger.error('An error occurred while bootstrapping Foam', e);\n    window.showErrorMessage(\n      `An error occurred while bootstrapping Foam. ${e.stack}`\n    );\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/block-rename-provider.spec.ts",
    "content": "/* @unit-ready */\nimport * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { FoamGraph } from '../core/model/graph';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { createTestWorkspace } from '../test/test-utils';\nimport { toVsCodeUri } from '../utils/vsc-utils';\nimport { BlockRenameProvider } from './block-rename-provider';\n\nconst parser = createMarkdownParser([]);\n\nconst buildFoamLike = (ws: ReturnType<typeof createTestWorkspace>) => ({\n  workspace: ws,\n  graph: FoamGraph.fromWorkspace(ws),\n  dispose: () => {},\n});\n\ndescribe('BlockRenameProvider', () => {\n  beforeEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n\n  describe('prepareRename', () => {\n    it('should return the block id range and placeholder when cursor is on a block anchor line', async () => {\n      const fileA = await createFile('A paragraph ^myblock\\n');\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const result = (await provider.prepareRename(\n        doc,\n        new vscode.Position(0, 'A paragraph ^m'.length), // cursor on the anchor id\n        new vscode.CancellationTokenSource().token\n      )) as { range: vscode.Range; placeholder: string };\n\n      expect(result.placeholder).toBe('myblock');\n      // range should cover just the id (after '^'), not the '^' itself\n      const caretCol = 'A paragraph ^'.length;\n      expect(result.range.start.character).toBe(caretCol);\n      expect(result.range.end.character).toBe(caretCol + 'myblock'.length);\n    });\n\n    it('should throw when cursor is not on a block anchor line', async () => {\n      const fileA = await createFile('Just a regular paragraph\\n');\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      await expect(\n        provider.prepareRename(\n          doc,\n          new vscode.Position(0, 0),\n          new vscode.CancellationTokenSource().token\n        )\n      ).rejects.toThrow('Cannot rename: cursor is not on a block anchor');\n    });\n\n    it('should throw when the anchor does not match a known block in the workspace', async () => {\n      // File not parsed into workspace — anchor exists in text but not in model\n      const fileA = await createFile('Some text ^unknownblock\\n');\n\n      const ws = createTestWorkspace(); // intentionally not adding fileA\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      await expect(\n        provider.prepareRename(\n          doc,\n          new vscode.Position(0, 0),\n          new vscode.CancellationTokenSource().token\n        )\n      ).rejects.toThrow('Cannot rename: cursor is not on a block anchor');\n    });\n  });\n\n  describe('provideRenameEdits', () => {\n    it('should update the block anchor text and all wikilinks referencing it', async () => {\n      const fileA = await createFile('A paragraph ^oldblock\\n');\n      const fileB = await createFile(\n        `See [[${fileA.name}#^oldblock]] for more.\\n`\n      );\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 15),\n        'newblock',\n        new vscode.CancellationTokenSource().token\n      );\n\n      expect(edits).toBeDefined();\n\n      // The anchor text edit on fileA: 'oldblock' → 'newblock'\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      expect(fileAEdits).toHaveLength(1);\n      expect(fileAEdits[0].newText).toBe('newblock');\n\n      // The wikilink update on fileB\n      const fileBEdits = edits.get(toVsCodeUri(fileB.uri));\n      expect(fileBEdits).toHaveLength(1);\n      expect(fileBEdits[0].newText).toContain('^newblock');\n      expect(fileBEdits[0].newText).not.toContain('^oldblock');\n    });\n\n    it('should update self-referencing block links within the same file', async () => {\n      const fileA = await createFile(\n        'A paragraph ^myblock\\n\\nJump to [[#^myblock]].\\n'\n      );\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 15),\n        'renamed',\n        new vscode.CancellationTokenSource().token\n      );\n\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      // One edit for anchor text, one for the self-referencing link\n      expect(fileAEdits).toHaveLength(2);\n      const newTexts = fileAEdits.map(e => e.newText);\n      expect(newTexts).toContain('renamed');\n      expect(newTexts.some(t => t.includes('[[#^renamed]]'))).toBe(true);\n    });\n\n    it('should not update links that reference a different block', async () => {\n      const fileA = await createFile('Para one ^block1\\n\\nPara two ^block2\\n');\n      const fileB = await createFile(`[[${fileA.name}#^block2]]\\n`);\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const foam = buildFoamLike(ws);\n      const provider = new BlockRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 12),\n        'renamed',\n        new vscode.CancellationTokenSource().token\n      );\n\n      // Only fileA anchor edit — fileB's link points to block2\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      expect(fileAEdits).toHaveLength(1);\n      expect(fileAEdits[0].newText).toBe('renamed');\n\n      const fileBEdits = edits.get(toVsCodeUri(fileB.uri));\n      expect(fileBEdits ?? []).toHaveLength(0);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/block-rename-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { Position } from '../core/model/position';\nimport { Range } from '../core/model/range';\nimport { HeadingEdit } from '../core/services/heading-edit';\nimport { WorkspaceTextEdit } from '../core/services/text-edit';\nimport { Logger } from '../core/utils/log';\nimport {\n  fromVsCodeUri,\n  toVsCodeRange,\n  toVsCodeWorkspaceEdit,\n} from '../utils/vsc-utils';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  context.subscriptions.push(\n    vscode.languages.registerRenameProvider(\n      'markdown',\n      new BlockRenameProvider(foam)\n    )\n  );\n}\n\nexport class BlockRenameProvider implements vscode.RenameProvider {\n  constructor(private foam: Foam) {}\n\n  async prepareRename(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    _token: vscode.CancellationToken\n  ): Promise<{ range: vscode.Range; placeholder: string }> {\n    const info = this.getBlockAnchorAtCursor(document, position);\n    if (!info) {\n      throw new Error('Cannot rename: cursor is not on a block anchor');\n    }\n    return {\n      range: info.idRange,\n      placeholder: info.blockId,\n    };\n  }\n\n  provideRenameEdits(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    newName: string,\n    _token: vscode.CancellationToken\n  ): vscode.ProviderResult<vscode.WorkspaceEdit> {\n    const info = this.getBlockAnchorAtCursor(document, position);\n    if (!info) {\n      throw new Error('Cannot rename: cursor is not on a block anchor');\n    }\n\n    const fileUri = fromVsCodeUri(document.uri);\n    const oldId = info.blockId;\n\n    // Edit 1: update the block anchor text in the current document\n    const anchorEdit: WorkspaceTextEdit = {\n      uri: fileUri,\n      edit: {\n        range: Range.create(\n          position.line,\n          info.idRange.start.character,\n          position.line,\n          info.idRange.end.character\n        ),\n        newText: newName,\n      },\n    };\n\n    // Edit 2+: update all links pointing to this block\n    const linkEditResult = HeadingEdit.createRenameBlockEdits(\n      this.foam.graph,\n      this.foam.workspace,\n      fileUri,\n      oldId,\n      newName\n    );\n\n    Logger.info(\n      `Renaming block \"^${oldId}\" to \"^${newName}\" (${linkEditResult.totalOccurrences} link(s) updated)`\n    );\n\n    return toVsCodeWorkspaceEdit(\n      [anchorEdit, ...linkEditResult.edits],\n      this.foam.workspace\n    );\n  }\n\n  /**\n   * If the cursor is within the `markerRange` of a block anchor that exists in\n   * the workspace resource, returns the block id and the VS Code range covering\n   * only the id text (after the `^`). Returns undefined otherwise.\n   */\n  private getBlockAnchorAtCursor(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): { blockId: string; idRange: vscode.Range } | undefined {\n    const resource = this.foam.workspace.find(fromVsCodeUri(document.uri));\n    if (!resource) {\n      return undefined;\n    }\n    const cursorPos = Position.create(position.line, position.character);\n    const block = resource.blocks.find(b =>\n      Range.containsPosition(b.markerRange, cursorPos)\n    );\n    if (!block) {\n      return undefined;\n    }\n    // idRange covers only the id text (after the `^`).\n    // markerRange.end.character - id.length gives the correct start regardless\n    // of whether the marker is inline (\" ^id\") or own-line (\"^id\").\n    const idStart = block.markerRange.end.character - block.id.length;\n    const idRange = toVsCodeRange(\n      Range.create(\n        block.markerRange.end.line,\n        idStart,\n        block.markerRange.end.line,\n        block.markerRange.end.character\n      )\n    );\n    return { blockId: block.id, idRange };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/convert-links.spec.ts",
    "content": "/* @unit-ready */\n\nimport * as vscode from 'vscode';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n  waitForNoteInFoamWorkspace,\n} from '../../test/test-utils-vscode';\nimport { deleteFile } from '../../services/editor';\nimport { Logger } from '../../core/utils/log';\nimport {\n  CONVERT_WIKILINK_TO_MDLINK,\n  CONVERT_MDLINK_TO_WIKILINK,\n} from './convert-links';\n\nLogger.setLevel('error');\n\ndescribe('Link Conversion Commands', () => {\n  beforeEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n\n  afterEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n\n  describe('foam-vscode.convert-wikilink-to-markdown', () => {\n    it('should convert wikilink to markdown link', async () => {\n      const noteA = await createFile('# Note A', ['note-a.md']);\n      const { uri } = await createFile('Text before [[note-a]] text after');\n      const { editor } = await showInEditor(uri);\n      await waitForNoteInFoamWorkspace(noteA.uri);\n\n      editor.selection = new vscode.Selection(0, 15, 0, 15);\n\n      await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);\n\n      const result = editor.document.getText();\n      expect(result).toBe('Text before [Note A](note-a.md) text after');\n\n      await deleteFile(noteA.uri);\n      await deleteFile(uri);\n    });\n\n    it('should position cursor at end of converted text', async () => {\n      const noteA = await createFile('# Note A', ['note-a.md']);\n      const { uri } = await createFile('Text before [[note-a]] text after');\n      const { editor } = await showInEditor(uri);\n      await waitForNoteInFoamWorkspace(noteA.uri);\n\n      editor.selection = new vscode.Selection(0, 15, 0, 15);\n\n      await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);\n\n      // Cursor should be at the end of the converted markdown link\n      const expectedPosition = 'Text before [Note A](note-a.md)'.length;\n      expect(editor.selection.active).toEqual(\n        new vscode.Position(0, expectedPosition)\n      );\n\n      await deleteFile(noteA.uri);\n      await deleteFile(uri);\n    });\n\n    it('should show info message when no wikilink at cursor', async () => {\n      const { uri } = await createFile('Text with no wikilinks');\n      const { editor } = await showInEditor(uri);\n\n      editor.selection = new vscode.Selection(0, 5, 0, 5);\n\n      const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');\n\n      await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);\n\n      expect(showInfoSpy).toHaveBeenCalledWith(\n        'No wikilink found at cursor position'\n      );\n\n      showInfoSpy.mockRestore();\n      await deleteFile(uri);\n    });\n\n    it('should show error when resource not found', async () => {\n      const { uri } = await createFile(\n        'Text before [[nonexistent-file]] text after'\n      );\n      const { editor } = await showInEditor(uri);\n\n      editor.selection = new vscode.Selection(0, 20, 0, 20);\n\n      const showErrorSpy = jest\n        .spyOn(vscode.window, 'showErrorMessage')\n        .mockResolvedValue(undefined);\n\n      Logger.setLevel('off');\n      await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);\n      Logger.setLevel('error');\n\n      expect(showErrorSpy).toHaveBeenCalled();\n\n      showErrorSpy.mockRestore();\n      await deleteFile(uri);\n    });\n  });\n\n  describe('foam-vscode.convert-markdown-to-wikilink', () => {\n    it('should convert markdown link to wikilink', async () => {\n      const noteA = await createFile('# Note A', ['note-a.md']);\n      const { uri } = await createFile(\n        'Text before [Note A](note-a.md) text after'\n      );\n      const { editor } = await showInEditor(uri);\n      await waitForNoteInFoamWorkspace(noteA.uri);\n\n      editor.selection = new vscode.Selection(0, 15, 0, 15);\n\n      await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);\n\n      const result = editor.document.getText();\n      expect(result).toBe('Text before [[note-a]] text after');\n\n      await deleteFile(uri);\n      await deleteFile(noteA.uri);\n    });\n\n    it('should position cursor at end of converted text', async () => {\n      const noteA = await createFile('# Note A', ['note-a.md']);\n      const { uri } = await createFile(\n        'Text before [Note A](note-a.md) text after'\n      );\n      const { editor } = await showInEditor(uri);\n\n      editor.selection = new vscode.Selection(0, 15, 0, 15);\n      await waitForNoteInFoamWorkspace(noteA.uri);\n\n      await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);\n\n      // Cursor should be at the end of the converted wikilink\n      const expectedPosition = 'Text before [[note-a]]'.length;\n      expect(editor.document.getText()).toBe(\n        'Text before [[note-a]] text after'\n      );\n      expect(editor.selection.active).toEqual(\n        new vscode.Position(0, expectedPosition)\n      );\n\n      await deleteFile(uri);\n      await deleteFile(noteA.uri);\n    });\n\n    it('should show info message when no markdown link at cursor', async () => {\n      const { uri } = await createFile('Text with no markdown links');\n      const { editor } = await showInEditor(uri);\n\n      editor.selection = new vscode.Selection(0, 5, 0, 5);\n\n      const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');\n\n      await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);\n\n      expect(showInfoSpy).toHaveBeenCalledWith(\n        'No markdown link found at cursor position'\n      );\n\n      showInfoSpy.mockRestore();\n      await deleteFile(uri);\n    });\n  });\n\n  describe('Command registration', () => {\n    it('should handle no active editor gracefully', async () => {\n      await closeEditors();\n\n      await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);\n      await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);\n\n      expect(true).toBe(true);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/convert-links.test.ts",
    "content": "import {\n  convertWikilinkToMarkdownAtPosition,\n  convertMarkdownToWikilinkAtPosition,\n} from './convert-links';\nimport { URI } from '../../core/model/uri';\nimport { Position } from '../../core/model/position';\nimport { Range } from '../../core/model/range';\nimport { TextEdit } from '../../core/services/text-edit';\nimport { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport { createMarkdownParser } from '../../core/services/markdown-parser';\n\ndescribe('Link Conversion Functions', () => {\n  describe('convertWikilinkToMarkdownAtPosition', () => {\n    it('should convert simple wikilink to markdown link', () => {\n      const documentText = 'Text before [[note-a]] text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 }; // Inside [[note-a]]\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/note-a.md', title: 'Note A' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertWikilinkToMarkdownAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[Note A](note-a.md)');\n      expect(result!.range).toEqual(Range.create(0, 12, 0, 22));\n\n      // Check the final result after applying the edit\n      const finalText = TextEdit.apply(documentText, result!);\n      expect(finalText).toBe('Text before [Note A](note-a.md) text after');\n    });\n\n    it('should convert wikilink with alias to markdown link', () => {\n      const documentText = 'Text before [[note-a|Custom Title]] text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/note-a.md', title: 'Note A' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertWikilinkToMarkdownAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[Custom Title](note-a.md)');\n\n      // Check the final result after applying the edit\n      const finalText = TextEdit.apply(documentText, result!);\n      expect(finalText).toBe(\n        'Text before [Custom Title](note-a.md) text after'\n      );\n    });\n\n    it('should handle subfolders paths correctly', () => {\n      const documentText = 'Text before [[path/to/note-b]] text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 20 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/path/to/note-b.md', title: 'Note B' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertWikilinkToMarkdownAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[Note B](path/to/note-b.md)');\n\n      // Check the final result after applying the edit\n      const finalText = TextEdit.apply(documentText, result!);\n      expect(finalText).toBe(\n        'Text before [Note B](path/to/note-b.md) text after'\n      );\n    });\n\n    it('should handle relative paths correctly', () => {\n      const documentText = 'Text before [[note-b]] text after';\n      const documentUri = URI.file('/test/sub1/current.md');\n      const linkPosition: Position = { line: 0, character: 20 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/sub2/note-b.md', title: 'Note B' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertWikilinkToMarkdownAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[Note B](../sub2/note-b.md)');\n\n      // Check the final result after applying the edit\n      const finalText = TextEdit.apply(documentText, result!);\n      expect(finalText).toBe(\n        'Text before [Note B](../sub2/note-b.md) text after'\n      );\n    });\n\n    it('should return null when no wikilink at cursor position', () => {\n      const documentText = 'Text with no wikilink at cursor';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 5 };\n\n      const workspace = createTestWorkspace();\n      const parser = createMarkdownParser();\n\n      const result = convertWikilinkToMarkdownAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).toBeNull();\n    });\n\n    it('should throw error when target resource not found', () => {\n      const documentText = 'Text before [[nonexistent]] text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace(); // Empty workspace\n      const parser = createMarkdownParser();\n\n      expect(() => {\n        convertWikilinkToMarkdownAtPosition(\n          documentText,\n          documentUri,\n          linkPosition,\n          workspace,\n          parser\n        );\n      }).toThrow('Resource \"nonexistent\" not found');\n    });\n  });\n\n  describe('convertMarkdownToWikilinkAtPosition', () => {\n    it('should convert simple markdown link to wikilink', () => {\n      const documentText = 'Text before [Note A](note-a.md) text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/note-a.md', title: 'Note A' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertMarkdownToWikilinkAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[[note-a]]');\n      expect(result!.range).toEqual(Range.create(0, 12, 0, 31));\n    });\n\n    it('should convert simple markdown link to other folder to wikilink', () => {\n      const documentText = 'Text before [Note A](docs/note-a.md) text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/docs/note-a.md', title: 'Note A' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertMarkdownToWikilinkAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[[note-a]]');\n      expect(result!.range).toEqual(Range.create(0, 12, 0, 36));\n    });\n\n    it('should preserve alias when different from title', () => {\n      const documentText = 'Text before [Custom Title](note-a.md) text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace().set(\n        createTestNote({ uri: '/test/note-a.md', title: 'Note A' })\n      );\n      const parser = createMarkdownParser();\n\n      const result = convertMarkdownToWikilinkAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).not.toBeNull();\n      expect(result!.newText).toBe('[[note-a|Custom Title]]');\n    });\n\n    it('should return null when no markdown link at cursor position', () => {\n      const documentText = 'Text with no markdown link at cursor';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 5 };\n\n      const workspace = createTestWorkspace();\n      const parser = createMarkdownParser();\n\n      const result = convertMarkdownToWikilinkAtPosition(\n        documentText,\n        documentUri,\n        linkPosition,\n        workspace,\n        parser\n      );\n\n      expect(result).toBeNull();\n    });\n\n    it('should throw error when target resource not found', () => {\n      const documentText = 'Text before [Link](nonexistent.md) text after';\n      const documentUri = URI.file('/test/current.md');\n      const linkPosition: Position = { line: 0, character: 15 };\n\n      const workspace = createTestWorkspace();\n      const parser = createMarkdownParser();\n\n      expect(() => {\n        convertMarkdownToWikilinkAtPosition(\n          documentText,\n          documentUri,\n          linkPosition,\n          workspace,\n          parser\n        );\n      }).toThrow('Resource not found: /test/nonexistent.md');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/convert-links.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { Resource, ResourceLink } from '../../core/model/note';\nimport { MarkdownLink } from '../../core/services/markdown-link';\nimport { Range } from '../../core/model/range';\nimport { Position } from '../../core/model/position';\nimport { URI } from '../../core/model/uri';\nimport { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';\nimport { Logger } from '../../core/utils/log';\nimport { TextEdit } from '../../core/services/text-edit';\n\nexport const CONVERT_WIKILINK_TO_MDLINK = {\n  command: 'foam-vscode.convert-wikilink-to-mdlink',\n  title: 'Foam: Convert Wikilink to Markdown Link',\n};\n\nexport const CONVERT_MDLINK_TO_WIKILINK = {\n  command: 'foam-vscode.convert-mdlink-to-wikilink',\n  title: 'Foam: Convert Markdown Link to Wikilink',\n};\n\n/**\n * Pure function to convert a wikilink to markdown link at a specific position\n * Returns the TextEdit to apply, or null if no conversion is possible\n */\nexport function convertWikilinkToMarkdownAtPosition(\n  documentText: string,\n  documentUri: URI,\n  linkPosition: Position,\n  foamWorkspace: { find: (identifier: string) => Resource | null },\n  foamParser: { parse: (uri: URI, text: string) => Resource }\n): TextEdit | null {\n  // Parse the document to get all links using Foam's parser\n  const resource = foamParser.parse(documentUri, documentText);\n\n  // Find the link at cursor position\n  const targetLink: ResourceLink | undefined = resource.links.find(\n    link =>\n      link.type === 'wikilink' &&\n      Range.containsPosition(link.range, linkPosition)\n  );\n\n  if (!targetLink) {\n    return null;\n  }\n\n  // Parse the link to get target and alias information\n  const linkInfo = MarkdownLink.analyzeLink(targetLink);\n\n  // Find the target resource in the workspace\n  const targetResource = foamWorkspace.find(linkInfo.target);\n  if (!targetResource) {\n    throw new Error(`Resource \"${linkInfo.target}\" not found`);\n  }\n\n  // Compute relative path from current file to target file\n  const currentDirectory = documentUri.getDirectory();\n  const relativePath = targetResource.uri.relativeTo(currentDirectory).path;\n\n  const alias = linkInfo.alias ? linkInfo.alias : targetResource.title;\n  return MarkdownLink.createUpdateLinkEdit(targetLink, {\n    type: 'link',\n    target: relativePath,\n    alias: alias,\n  });\n}\n\n/**\n * Pure function to convert a markdown link to wikilink at a specific position\n * Returns the TextEdit to apply, or null if no conversion is possible\n */\nexport function convertMarkdownToWikilinkAtPosition(\n  documentText: string,\n  documentUri: URI,\n  cursorPosition: Position,\n  foamWorkspace: {\n    resolveLink: (resource: Resource, link: ResourceLink) => URI;\n    get: (uri: URI) => Resource | null;\n    getIdentifier: (uri: URI) => string;\n  },\n  foamParser: { parse: (uri: URI, text: string) => Resource }\n): TextEdit | null {\n  // Parse the document to get all links using Foam's parser\n  const resource = foamParser.parse(documentUri, documentText);\n\n  // Find the link at cursor position\n  const targetLink: ResourceLink | undefined = resource.links.find(\n    link =>\n      link.type === 'link' && Range.containsPosition(link.range, cursorPosition)\n  );\n\n  if (!targetLink) {\n    return null;\n  }\n\n  // Parse the link to get target and alias information\n  const linkInfo = MarkdownLink.analyzeLink(targetLink);\n\n  // Try to resolve the target resource from the link\n  const targetUri = foamWorkspace.resolveLink(resource, targetLink);\n  const targetResource = foamWorkspace.get(targetUri);\n\n  if (!targetResource) {\n    throw new Error(`Resource not found: ${targetUri.path}`);\n  }\n\n  // Get the workspace identifier for the target resource\n  const identifier = foamWorkspace.getIdentifier(targetResource.uri);\n\n  return MarkdownLink.createUpdateLinkEdit(targetLink, {\n    type: 'wikilink',\n    target: identifier,\n    alias:\n      linkInfo.alias && linkInfo.alias !== targetResource.title\n        ? linkInfo.alias\n        : '',\n  });\n}\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    vscode.commands.registerCommand(CONVERT_WIKILINK_TO_MDLINK.command, () =>\n      convertWikilinkToMarkdown(foam)\n    ),\n\n    vscode.commands.registerCommand(CONVERT_MDLINK_TO_WIKILINK.command, () =>\n      convertMarkdownToWikilink(foam)\n    )\n  );\n}\n\n/**\n * Convert wikilink at cursor position to markdown link format\n */\nexport async function convertWikilinkToMarkdown(foam: Foam): Promise<void> {\n  const activeEditor = vscode.window.activeTextEditor;\n  if (!activeEditor) {\n    return;\n  }\n\n  const document = activeEditor.document;\n  const position = activeEditor.selection.active;\n\n  try {\n    const edit = convertWikilinkToMarkdownAtPosition(\n      document.getText(),\n      fromVsCodeUri(document.uri),\n      {\n        line: position.line,\n        character: position.character,\n      },\n      foam.workspace,\n      foam.services.parser\n    );\n\n    if (!edit) {\n      vscode.window.showInformationMessage(\n        'No wikilink found at cursor position'\n      );\n      return;\n    }\n\n    // Apply the edit to the document\n    const range = toVsCodeRange(edit.range);\n    const success = await activeEditor.edit(editBuilder => {\n      editBuilder.replace(range, edit.newText);\n    });\n\n    // Position cursor at the end of the updated text\n    if (success) {\n      const newEndPosition = new vscode.Position(\n        range.start.line,\n        range.start.character + edit.newText.length\n      );\n      activeEditor.selection = new vscode.Selection(\n        newEndPosition,\n        newEndPosition\n      );\n    }\n  } catch (error) {\n    Logger.error('Failed to convert wikilink to markdown link', error);\n    vscode.window.showErrorMessage(\n      `Failed to convert wikilink to markdown link: ${error.message}`\n    );\n  }\n}\n\n/**\n * Convert markdown link at cursor position to wikilink format\n */\nexport async function convertMarkdownToWikilink(foam: Foam): Promise<void> {\n  const activeEditor = vscode.window.activeTextEditor;\n  if (!activeEditor) {\n    return;\n  }\n\n  const document = activeEditor.document;\n  const position = activeEditor.selection.active;\n\n  try {\n    const edit = convertMarkdownToWikilinkAtPosition(\n      document.getText(),\n      fromVsCodeUri(document.uri),\n      {\n        line: position.line,\n        character: position.character,\n      },\n      foam.workspace,\n      foam.services.parser\n    );\n\n    if (!edit) {\n      vscode.window.showInformationMessage(\n        'No markdown link found at cursor position'\n      );\n      return;\n    }\n\n    // Apply the edit to the document\n    const range = toVsCodeRange(edit.range);\n    const success = await activeEditor.edit(editBuilder => {\n      editBuilder.replace(range, edit.newText);\n    });\n\n    // Position cursor at the end of the updated text\n    if (success) {\n      const newEndPosition = new vscode.Position(\n        range.start.line,\n        range.start.character + edit.newText.length\n      );\n      activeEditor.selection = new vscode.Selection(\n        newEndPosition,\n        newEndPosition\n      );\n    }\n  } catch (error) {\n    Logger.error('Failed to convert markdown link to wikilink', error);\n    vscode.window.showErrorMessage(\n      `Failed to convert markdown link to wikilink: ${error.message}`\n    );\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/copy-without-brackets.spec.ts",
    "content": "/* @unit-ready */\nimport { env, Selection, commands } from 'vscode';\nimport { createFile, showInEditor } from '../../test/test-utils-vscode';\nimport { removeBrackets, toTitleCase } from './copy-without-brackets';\n\ndescribe('copy-without-brackets command', () => {\n  it('should get the input from the active editor selection', async () => {\n    const { uri } = await createFile('This is my [[test-content]].', [\n      'copy-without-brackets',\n      'file.md',\n    ]);\n    const { editor } = await showInEditor(uri);\n    editor.selection = new Selection(0, 0, 1, 0);\n    await commands.executeCommand('foam-vscode.copy-without-brackets');\n    const value = await env.clipboard.readText();\n    expect(value).toEqual('This is my Test Content.');\n  });\n});\n\ndescribe('removeBrackets', () => {\n  it('removes the brackets', () => {\n    const input = 'hello world [[this-is-it]]';\n    const actual = removeBrackets(input);\n    const expected = 'hello world This Is It';\n    expect(actual).toEqual(expected);\n  });\n  it('removes the brackets and the md file extension', () => {\n    const input = 'hello world [[this-is-it.md]]';\n    const actual = removeBrackets(input);\n    const expected = 'hello world This Is It';\n    expect(actual).toEqual(expected);\n  });\n  it('removes the brackets and the mdx file extension', () => {\n    const input = 'hello world [[this-is-it.mdx]]';\n    const actual = removeBrackets(input);\n    const expected = 'hello world This Is It';\n    expect(actual).toEqual(expected);\n  });\n  it('removes the brackets and the markdown file extension', () => {\n    const input = 'hello world [[this-is-it.markdown]]';\n    const actual = removeBrackets(input);\n    const expected = 'hello world This Is It';\n    expect(actual).toEqual(expected);\n  });\n  it('removes the brackets even with numbers', () => {\n    const input = 'hello world [[2020-07-21.markdown]]';\n    const actual = removeBrackets(input);\n    const expected = 'hello world 2020 07 21';\n    expect(actual).toEqual(expected);\n  });\n  it('removes brackets for more than one word', () => {\n    const input =\n      'I am reading this as part of the [[book-club]] put on by [[egghead]] folks (Lauro).';\n    const actual = removeBrackets(input);\n    const expected =\n      'I am reading this as part of the Book Club put on by Egghead folks (Lauro).';\n    expect(actual).toEqual(expected);\n  });\n});\n\ndescribe('toTitleCase', () => {\n  it('title cases a word', () => {\n    const input =\n      'look at this really long sentence but I am calling it a word';\n    const actual = toTitleCase(input);\n    const expected =\n      'Look At This Really Long Sentence But I Am Calling It A Word';\n    expect(actual).toEqual(expected);\n  });\n  it('works on one word', () => {\n    const input = 'word';\n    const actual = toTitleCase(input);\n    const expected = 'Word';\n    expect(actual).toEqual(expected);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/copy-without-brackets.ts",
    "content": "import { window, env, ExtensionContext, commands } from 'vscode';\n\nexport default async function activate(context: ExtensionContext) {\n  context.subscriptions.push(\n    commands.registerCommand(\n      'foam-vscode.copy-without-brackets',\n      copyWithoutBrackets\n    )\n  );\n}\n\nasync function copyWithoutBrackets() {\n  // Get the active text editor\n  const editor = window.activeTextEditor;\n\n  if (editor) {\n    const document = editor.document;\n    const selection = editor.selection;\n\n    // Get the words within the selection\n    const text = document.getText(selection);\n\n    // Remove brackets from text\n    const modifiedText = removeBrackets(text);\n\n    // Copy to the clipboard\n    await env.clipboard.writeText(modifiedText);\n\n    // Alert the user it was successful\n    window.showInformationMessage('Successfully copied to clipboard!');\n  }\n}\n\n/**\n * Used for the \"Copy to Clipboard Without Brackets\" command\n *\n */\nexport function removeBrackets(s: string): string {\n  // take in the string, split on space\n  const stringSplitBySpace = s.split(' ');\n\n  // loop through words\n  const modifiedWords = stringSplitBySpace.map(currentWord => {\n    if (currentWord.includes('[[')) {\n      // all of these transformations will turn this \"[[you-are-awesome]]\"\n      // to this \"you are awesome\"\n      let word = currentWord.replace(/(\\[\\[)/g, '');\n      word = word.replace(/(\\]\\])/g, '');\n      word = word.replace(/(.mdx|.md|.markdown)/g, '');\n      word = word.replace(/[-]/g, ' ');\n\n      // then we titlecase the word so \"you are awesome\"\n      // becomes \"You Are Awesome\"\n      const titleCasedWord = toTitleCase(word);\n\n      return titleCasedWord;\n    }\n\n    return currentWord;\n  });\n\n  return modifiedWords.join(' ');\n}\n\n/**\n * Takes in a string and returns it titlecased\n *\n * @example toTitleCase(\"hello world\") -> \"Hello World\"\n */\nexport function toTitleCase(word: string): string {\n  return word\n    .split(' ')\n    .map(word => word[0].toUpperCase() + word.substring(1))\n    .join(' ');\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/create-new-template.ts",
    "content": "import { commands, ExtensionContext } from 'vscode';\nimport { createTemplate } from '../../services/templates';\n\nexport default async function activate(context: ExtensionContext) {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.create-new-template', createTemplate)\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/create-note-from-template.spec.ts",
    "content": "/* @unit-ready */\nimport { commands, window, workspace } from 'vscode';\nimport { toVsCodeUri } from '../../utils/vsc-utils';\nimport { cleanWorkspace, createFile } from '../../test/test-utils-vscode';\n\ndescribe('create-note-from-template command', () => {\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  it('offers to create template when none are available', async () => {\n    const spy = jest\n      .spyOn(window, 'showQuickPick')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n\n    await commands.executeCommand('foam-vscode.create-note-from-template');\n\n    expect(spy).toHaveBeenCalledWith(['Yes', 'No'], {\n      placeHolder:\n        'No templates available. Would you like to create one instead?',\n    });\n  });\n\n  it('offers to pick which template to use', async () => {\n    const templateA = await createFile('Template A', [\n      '.foam',\n      'templates',\n      'template-a.md',\n    ]);\n    const templateB = await createFile('Template A', [\n      '.foam',\n      'templates',\n      'template-b.md',\n    ]);\n\n    const spy = jest\n      .spyOn(window, 'showQuickPick')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n\n    await commands.executeCommand('foam-vscode.create-note-from-template');\n\n    expect(spy).toHaveBeenCalledWith(\n      [\n        expect.objectContaining({ label: 'template-a.md' }),\n        expect.objectContaining({ label: 'template-b.md' }),\n      ],\n      {\n        placeHolder: 'Select a template to use.',\n      }\n    );\n\n    await workspace.fs.delete(toVsCodeUri(templateA.uri));\n    await workspace.fs.delete(toVsCodeUri(templateB.uri));\n  });\n\n  it('Uses template metadata to improve dialog box', async () => {\n    const templateA = await createFile(\n      `---\nfoam_template:\n  name: My Template\n  description: My Template description\n---\n\nTemplate A\n      `,\n      ['.foam', 'templates', 'template-a.md']\n    );\n\n    const spy = jest\n      .spyOn(window, 'showQuickPick')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n\n    await commands.executeCommand('foam-vscode.create-note-from-template');\n\n    expect(spy).toHaveBeenCalledWith(\n      [\n        expect.objectContaining({\n          label: 'My Template',\n          description: 'template-a.md',\n          detail: 'My Template description',\n        }),\n      ],\n      expect.anything()\n    );\n\n    await workspace.fs.delete(toVsCodeUri(templateA.uri));\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/create-note-from-template.ts",
    "content": "import { commands, ExtensionContext } from 'vscode';\n\nexport default async function activate(context: ExtensionContext) {\n  context.subscriptions.push(\n    commands.registerCommand(\n      'foam-vscode.create-note-from-template',\n      async () => {\n        await commands.executeCommand('foam-vscode.create-note', {\n          askForTemplate: true,\n        });\n      }\n    )\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/create-note.spec.ts",
    "content": "/* @unit-ready */\nimport { commands, window, workspace } from 'vscode';\nimport { URI } from '../../core/model/uri';\nimport { asAbsoluteWorkspaceUri, readFile } from '../../services/editor';\nimport {\n  closeEditors,\n  createFile,\n  deleteFile,\n  expectSameUri,\n  getUriInWorkspace,\n  makeFoamMock,\n  showInEditor,\n} from '../../test/test-utils-vscode';\nimport { fromVsCodeUri } from '../../utils/vsc-utils';\nimport { CREATE_NOTE_COMMAND, createNote } from './create-note';\nimport { Location } from '../../core/model/location';\nimport { Range } from '../../core/model/range';\nimport { ResourceLink } from '../../core/model/note';\nimport { createMarkdownParser } from '../../core/services/markdown-parser';\n\ndescribe('create-note command', () => {\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  it('uses sensible defaults to work even without params', async () => {\n    const spy = jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve('Test note')));\n\n    await commands.executeCommand('foam-vscode.create-note');\n    expect(spy).toHaveBeenCalled();\n    const target = asAbsoluteWorkspaceUri(URI.file('Test note.md'));\n    expectSameUri(target, window.activeTextEditor?.document.uri);\n    await deleteFile(target);\n  });\n\n  it('gives precedence to the template over the text', async () => {\n    const templateA = await createFile('Template A', [\n      '.foam',\n      'templates',\n      'template-for-create-note.md',\n    ]);\n    const target = getUriInWorkspace();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target,\n      templatePath: templateA.uri.path,\n      text: 'hello',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual('Template A');\n    expectSameUri(window.activeTextEditor.document.uri, target);\n    await deleteFile(target);\n    await deleteFile(templateA.uri);\n  });\n\n  it('focuses on the newly created note', async () => {\n    const target = getUriInWorkspace();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target,\n      text: 'hello',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual('hello');\n    expectSameUri(window.activeTextEditor.document.uri, target);\n    await deleteFile(target);\n  });\n\n  it('supports variables', async () => {\n    const target = getUriInWorkspace();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target,\n      text: 'hello ${FOAM_TITLE}', // eslint-disable-line no-template-curly-in-string\n      variables: { FOAM_TITLE: 'world' },\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual('hello world');\n    expectSameUri(window.activeTextEditor.document.uri, target);\n    await deleteFile(target);\n  });\n\n  it('supports date variables', async () => {\n    const target = getUriInWorkspace();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target,\n      text: 'hello ${FOAM_DATE_YEAR}', // eslint-disable-line no-template-curly-in-string\n      date: '2021-10-01',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual('hello 2021');\n    expectSameUri(window.activeTextEditor.document.uri, target);\n    await deleteFile(target);\n  });\n\n  it('supports various options to deal with existing notes', async () => {\n    const target = await createFile('hello');\n    const content = await readFile(target.uri);\n    expect(content).toEqual('hello');\n\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target.uri,\n      text: 'test overwrite',\n      onFileExists: 'overwrite',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'test overwrite'\n    );\n    expectSameUri(window.activeTextEditor.document.uri, target.uri);\n\n    await closeEditors();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target.uri,\n      text: 'test open',\n      onFileExists: 'open',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'test overwrite'\n    );\n    expectSameUri(window.activeTextEditor.document.uri, target.uri);\n\n    await closeEditors();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target.uri,\n      text: 'test cancel',\n      onFileExists: 'cancel',\n    });\n    expect(window.activeTextEditor).toBeUndefined();\n\n    const spy = jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n    await closeEditors();\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: target.uri,\n      text: 'test ask',\n      onFileExists: 'ask',\n    });\n    expect(spy).toHaveBeenCalled();\n\n    await deleteFile(target);\n  });\n\n  it('supports various options to deal with relative paths', async () => {\n    const TEST_FOLDER = 'create-note-tests';\n    const base = await createFile('relative path tests base file', [\n      TEST_FOLDER,\n      'base-file.md',\n    ]);\n\n    await closeEditors();\n    await showInEditor(base.uri);\n    expectSameUri(window.activeTextEditor.document.uri, base.uri);\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: 'note-resolved-from-root.md',\n      text: 'test resolving from root',\n      onRelativeNotePath: 'resolve-from-root',\n    });\n    expectSameUri(\n      window.activeTextEditor.document.uri,\n      fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(\n        'note-resolved-from-root.md'\n      )\n    );\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'test resolving from root'\n    );\n\n    await closeEditors();\n    await showInEditor(base.uri);\n    expectSameUri(window.activeTextEditor.document.uri, base.uri);\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: 'note-resolved-from-current-dir.md',\n      text: 'test resolving from current dir',\n      onRelativeNotePath: 'resolve-from-current-dir',\n    });\n    expectSameUri(\n      window.activeTextEditor.document.uri,\n      fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(\n        TEST_FOLDER,\n        'note-resolved-from-current-dir.md'\n      )\n    );\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'test resolving from current dir'\n    );\n\n    await closeEditors();\n    await showInEditor(base.uri);\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: 'note-that-should-not-be-created.md',\n      text: 'test cancelling',\n      onRelativeNotePath: 'cancel',\n    });\n    expectSameUri(window.activeTextEditor.document.uri, base.uri);\n\n    await closeEditors();\n    await showInEditor(base.uri);\n    const spy = jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: 'ask-me-about-it.md',\n      text: 'test asking',\n      onRelativeNotePath: 'ask',\n    });\n    expect(spy).toHaveBeenCalled();\n\n    // await deleteFile(base);\n  });\n\n  it('throws an error if the template file does not exist', async () => {\n    const nonExistentTemplatePath = '/non/existent/template/path.md';\n    await expect(\n      commands.executeCommand('foam-vscode.create-note', {\n        notePath: 'note-with-missing-template.md',\n        templatePath: nonExistentTemplatePath,\n        text: 'should not matter',\n      })\n    ).rejects.toThrow(\n      `Failed to load template (file://${nonExistentTemplatePath}): Template file not found: file://${nonExistentTemplatePath}`\n    );\n  });\n\n  it('throws an error if the template file does not exist (relative path)', async () => {\n    try {\n      const nonExistentTemplatePath = 'relative/non-existent-template.md';\n      await commands.executeCommand('foam-vscode.create-note', {\n        notePath: 'note-with-missing-template-relative.md',\n        templatePath: nonExistentTemplatePath,\n        text: 'should not matter',\n      });\n      throw new Error('Expected an error to be thrown');\n    } catch (error) {\n      expect(error.message).toContain(`Failed to load template`); // eslint-disable-line jest/no-conditional-expect\n    }\n  });\n\n  it('creates a note with absolute path within the workspace', async () => {\n    await commands.executeCommand('foam-vscode.create-note', {\n      notePath: '/note-in-workspace.md',\n      text: 'hello workspace',\n    });\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'hello workspace'\n    );\n    expectSameUri(\n      window.activeTextEditor.document.uri,\n      fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(\n        'note-in-workspace.md'\n      )\n    );\n    await deleteFile(\n      fromVsCodeUri(workspace.workspaceFolders?.[0].uri).joinPath(\n        'note-in-workspace.md'\n      )\n    );\n  });\n});\n\ndescribe('factories', () => {\n  describe('forPlaceholder', () => {\n    it('adds the .md extension to notes created for placeholders', async () => {\n      await closeEditors();\n      const link: ResourceLink = {\n        type: 'wikilink',\n        rawText: '[[my-placeholder]]',\n        range: Range.create(0, 0, 0, 0),\n        isEmbed: false,\n      };\n      const command = CREATE_NOTE_COMMAND.forPlaceholder(\n        Location.forObjectWithRange(URI.file(''), link),\n        '.md'\n      );\n      await commands.executeCommand(command.name, command.params);\n\n      const doc = window.activeTextEditor.document;\n      expect(doc.uri.path).toMatch(/my-placeholder.md$/);\n      expect(doc.getText()).toMatch(/^# my-placeholder/);\n    });\n\n    it('replaces the original placeholder based on the new note identifier (#1327)', async () => {\n      await closeEditors();\n      const templateA = await createFile(\n        `---\nfoam_template:\n  name: 'Example Template'\n  description: 'An example for reproducing a bug'\n  filepath: '$FOAM_SLUG-world.md'\n---`,\n        ['.foam', 'templates', 'template-a.md']\n      );\n\n      const noteA = await createFile(`this is my [[hello]]`);\n\n      const parser = createMarkdownParser();\n      const res = parser.parse(noteA.uri, noteA.content);\n\n      const command = CREATE_NOTE_COMMAND.forPlaceholder(\n        Location.forObjectWithRange(noteA.uri, res.links[0]),\n        '.md',\n        {\n          templatePath: templateA.uri.path,\n        }\n      );\n      const results: Awaited<ReturnType<typeof createNote>> =\n        await commands.executeCommand(command.name, command.params);\n      expect(results.didCreateFile).toBeTruthy();\n      expect(results.uri.path).toMatch(/hello-world.md$/);\n\n      const newNoteDoc = window.activeTextEditor.document;\n      expect(newNoteDoc.uri.path).toMatch(/hello-world.md$/);\n\n      const { doc } = await showInEditor(noteA.uri);\n      expect(doc.getText()).toEqual(`this is my [[hello-world]]`);\n    });\n  });\n\n  describe('Template filepath with FOAM_CURRENT_DIR', () => {\n    it('should create note in current directory using FOAM_CURRENT_DIR variable', async () => {\n      // Create a test subdirectory and a file in it\n      const noteInSubdir = await createFile('Test content', [\n        'subdir',\n        'existing-note.md',\n      ]);\n\n      // Create a template with FOAM_CURRENT_DIR variable\n      const template = await createFile(\n        `---\nfoam_template:\n  filepath: \\${FOAM_CURRENT_DIR}/\\${FOAM_SLUG}.md  \n---\n# \\${FOAM_TITLE}\n\nTemplate content using FOAM_CURRENT_DIR`,\n        ['.foam', 'templates', 'foam-current-dir-template.md']\n      );\n\n      // Switch to the file in the subdirectory to set current editor context\n      await showInEditor(noteInSubdir.uri);\n\n      // Create a note using the template - FOAM_CURRENT_DIR should resolve to current editor directory\n      const resultInSubdir = await createNote(\n        {\n          templatePath: template.uri.path,\n          title: 'My New Note',\n        },\n        makeFoamMock()\n      );\n      // The note should be created in the subdir because FOAM_CURRENT_DIR resolves to current editor directory\n      expect(resultInSubdir.uri).toEqual(\n        noteInSubdir.uri.getDirectory().joinPath('my-new-note.md')\n      );\n\n      await closeEditors();\n      // Create a note using the template - FOAM_CURRENT_DIR should resolve to current editor directory\n      const resultInRoot = await createNote(\n        {\n          templatePath: template.uri.path,\n          title: 'My New Note',\n        },\n        makeFoamMock()\n      );\n      // The note should be created in the workspace root because FOAM_CURRENT_DIR resolves to workspace root when no editor is active\n      expect(resultInRoot.uri).toEqual(\n        fromVsCodeUri(workspace.workspaceFolders[0].uri).joinPath(\n          'my-new-note.md'\n        )\n      );\n\n      // Clean up\n      await deleteFile(template.uri);\n      await deleteFile(noteInSubdir.uri);\n      await deleteFile(resultInRoot.uri);\n      await deleteFile(resultInSubdir.uri);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/create-note.ts",
    "content": "import { workspace, commands, WorkspaceEdit, ExtensionContext } from 'vscode';\nimport { URI } from '../../core/model/uri';\nimport {\n  askUserForTemplate,\n  getDefaultTemplateUri,\n  NoteFactory,\n} from '../../services/templates';\nimport { NoteCreationEngine } from '../../services/note-creation-engine';\nimport { TriggerFactory } from '../../services/note-creation-triggers';\nimport { TemplateLoader } from '../../services/template-loader';\nimport { Template } from '../../services/note-creation-types';\nimport { Resolver } from '../../services/variable-resolver';\nimport { asAbsoluteWorkspaceUri, fileExists } from '../../services/editor';\nimport { CommandDescriptor } from '../../utils/commands';\nimport { Foam } from '../../core/model/foam';\nimport { Location } from '../../core/model/location';\nimport { MarkdownLink } from '../../core/services/markdown-link';\nimport { ResourceLink } from '../../core/model/note';\nimport { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils';\nimport { Logger } from '../../core/utils/log';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  context.subscriptions.push(\n    commands.registerCommand(CREATE_NOTE_COMMAND.command, args =>\n      createNote(args, foam)\n    )\n  );\n}\n\ninterface CreateNoteArgs {\n  /**\n   * The path of the note to create.\n   * If relative it will be resolved against the workspace root.\n   */\n  notePath?: string | URI;\n  /**\n   * The path of the template to use.\n   */\n  templatePath?: string | URI;\n  /**\n   * Whether to ask the user to select a template for the new note. If so, overwrites templatePath.\n   */\n  askForTemplate?: boolean;\n  /**\n   * The text to use for the note.\n   * If a template is provided, the template has precedence\n   */\n  text?: string;\n  /**\n   * Variables to use in the text or template\n   */\n  variables?: { [key: string]: string };\n  /**\n   * The date used to resolve the FOAM_DATE_* variables. in YYYY-MM-DD format\n   */\n  date?: string | Date;\n  /**\n   * The title of the note (translates into the FOAM_TITLE variable)\n   */\n  title?: string;\n  /**\n   * The source link that triggered the creation of the note.\n   * It will be updated with the appropriate identifier to the note, if necessary.\n   */\n  sourceLink?: Location<ResourceLink>;\n  /**\n   * What to do in case the target file already exists\n   */\n  onFileExists?: 'overwrite' | 'open' | 'ask' | 'cancel';\n  /**\n   * What to do if the new note path is relative\n   */\n  onRelativeNotePath?:\n    | 'resolve-from-root'\n    | 'resolve-from-current-dir'\n    | 'ask'\n    | 'cancel';\n}\n\nconst DEFAULT_NEW_NOTE_TEXT = `# \\${FOAM_TITLE}\n\n\\${FOAM_SELECTED_TEXT}`;\n\n/**\n * Related to #1505.\n * This function forces the date to be local by removing any time information and\n * adding a local time (noon) to it.\n * @param dateString The date string, either in YYYY-MM-DD format or any format parsable by Date()\n * @returns The parsed Date object\n */\nfunction forceLocalDate(dateString: string): Date {\n  // Remove the time part if present\n  const dateOnly = dateString.split('T')[0];\n  // Otherwise, treat as local date by adding local noon time\n  return new Date(dateOnly + 'T12:00:00');\n}\n\nexport async function createNote(args: CreateNoteArgs, foam: Foam) {\n  args = args ?? {};\n  const foamDate =\n    typeof args.date === 'string'\n      ? forceLocalDate(args.date)\n      : args.date instanceof Date\n      ? args.date\n      : new Date();\n\n  // Create appropriate trigger based on context\n  const trigger = args.sourceLink\n    ? TriggerFactory.createPlaceholderTrigger(\n        args.sourceLink.uri,\n        foam.workspace.find(new URI(args.sourceLink.uri))?.title || 'Unknown',\n        args.sourceLink\n      )\n    : TriggerFactory.createCommandTrigger('foam-vscode.create-note');\n\n  // Determine template path\n  let templateUri: URI;\n  if (args.askForTemplate) {\n    const selectedTemplate = await askUserForTemplate();\n    if (selectedTemplate) {\n      templateUri = selectedTemplate;\n    } else {\n      return;\n    }\n  } else {\n    templateUri = args.templatePath\n      ? asAbsoluteWorkspaceUri(args.templatePath)\n      : await getDefaultTemplateUri();\n  }\n\n  // Load template using the new system\n  const templateLoader = new TemplateLoader();\n  let template: Template;\n\n  try {\n    if (!templateUri) {\n      template = {\n        type: 'markdown',\n        metadata: new Map(),\n        content: args.text || DEFAULT_NEW_NOTE_TEXT,\n      };\n    } else if (await fileExists(templateUri)) {\n      template = await templateLoader.loadTemplate(templateUri);\n    } else {\n      throw new Error(`Template file not found: ${templateUri}`);\n    }\n  } catch (error) {\n    throw new Error(\n      `Failed to load template (${templateUri}): ${error.message}`\n    );\n  }\n\n  // If notePath is provided, add it to template metadata to avoid unnecessary title resolution\n  if (args.notePath && template.type === 'markdown') {\n    template.metadata.set(\n      'filepath',\n      typeof args.notePath === 'string'\n        ? args.notePath\n        : args.notePath.toFsPath()\n    );\n  }\n\n  // Create resolver with all variables upfront\n  const resolver = new Resolver(\n    new Map(Object.entries(args.variables ?? {})),\n    foamDate,\n    args.title\n  );\n\n  if (Logger.getLevel() === 'debug') {\n    Logger.debug(`[createNote] args: ${JSON.stringify(args, null, 2)}`);\n    Logger.debug(`[createNote] template: ${JSON.stringify(template, null, 2)}`);\n    Logger.debug(`[createNote] resolver: ${JSON.stringify(resolver, null, 2)}`);\n    Logger.debug(\n      `[createNote] foamDate: ${foamDate.toISOString()} (timezone offset: ${foamDate.getTimezoneOffset()})`\n    );\n  }\n\n  // Process template using the new engine with unified resolver\n  const engine = new NoteCreationEngine(foam);\n  const result = await engine.processTemplate(trigger, template, resolver);\n\n  // Create the note using NoteFactory with the same resolver\n  const createdNote = await NoteFactory.createNote(\n    result.filepath,\n    result.content,\n    resolver,\n    args.onFileExists,\n    args.onRelativeNotePath\n  );\n\n  // Handle source link updates for placeholders\n  if (args.sourceLink && createdNote.uri) {\n    const identifier = foam.workspace.getIdentifier(createdNote.uri);\n    const edit = MarkdownLink.createUpdateLinkEdit(args.sourceLink.data, {\n      target: identifier,\n    });\n    if (edit.newText !== args.sourceLink.data.rawText) {\n      const updateLink = new WorkspaceEdit();\n      const uri = toVsCodeUri(args.sourceLink.uri);\n      updateLink.replace(\n        uri,\n        toVsCodeRange(args.sourceLink.range),\n        edit.newText\n      );\n      await workspace.applyEdit(updateLink);\n    }\n  }\n\n  return createdNote;\n}\n\nexport const CREATE_NOTE_COMMAND = {\n  command: 'foam-vscode.create-note',\n\n  /**\n   * Creates a command descriptor to create a note from the given placeholder.\n   *\n   * @param placeholder the placeholder\n   * @param defaultExtension the default extension (e.g. '.md')\n   * @param extra extra command arguments\n   * @returns the command descriptor\n   */\n  forPlaceholder: (\n    sourceLink: Location<ResourceLink>,\n    defaultExtension: string,\n    extra: Partial<CreateNoteArgs> = {}\n  ): CommandDescriptor<CreateNoteArgs> => {\n    const endsWithDefaultExtension = new RegExp(defaultExtension + '$');\n    const { target: placeholder } = MarkdownLink.analyzeLink(sourceLink.data);\n    const title = placeholder.endsWith(defaultExtension)\n      ? placeholder.replace(endsWithDefaultExtension, '')\n      : placeholder;\n    const notePath = placeholder.endsWith(defaultExtension)\n      ? placeholder\n      : placeholder + defaultExtension;\n    return {\n      name: CREATE_NOTE_COMMAND.command,\n      params: {\n        title,\n        notePath,\n        sourceLink,\n        ...extra,\n      },\n    };\n  },\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/index.ts",
    "content": "export { default as copyWithoutBracketsCommand } from './copy-without-brackets';\nexport { default as createFromTemplateCommand } from './create-note-from-template';\nexport { default as createNewTemplate } from './create-new-template';\nexport { default as janitorCommand } from './janitor';\nexport { default as openDailyNoteCommand } from './open-daily-note';\nexport { default as openDailyNoteForDateCommand } from './open-daily-note-for-date';\nexport { default as openDatedNote } from './open-dated-note';\nexport { default as openRandomNoteCommand } from './open-random-note';\nexport { default as openResource } from './open-resource';\nexport { default as updateGraphCommand } from './update-graph';\nexport { default as updateWikilinksCommand } from './update-wikilinks';\nexport { default as createNote } from './create-note';\nexport { default as searchTagCommand } from './search-tag';\nexport { default as renameTagCommand } from './rename-tag';\nexport { default as convertLinksCommand } from './convert-links';\nexport { default as showSimilarNotesCommand } from '../../ai/vscode/commands/show-similar-notes';\nexport { default as buildEmbeddingsCommand } from '../../ai/vscode/commands/build-embeddings';\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/janitor.ts",
    "content": "import {\n  window,\n  workspace,\n  ExtensionContext,\n  commands,\n  ProgressLocation,\n} from 'vscode';\nimport detectNewline from 'detect-newline';\nimport { Foam } from '../../core/model/foam';\nimport { Resource } from '../../core/model/note';\nimport { generateHeading, generateLinkReferences } from '../../core/janitor';\nimport { Range } from '../../core/model/range';\nimport { TextEdit } from '../../core/services/text-edit';\nimport {\n  toVsCodePosition,\n  toVsCodeRange,\n  toVsCodeUri,\n} from '../../utils/vsc-utils';\nimport { getWikilinkDefinitionSetting } from './update-wikilinks';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.janitor', async () =>\n      janitor(await foamPromise)\n    )\n  );\n}\n\nasync function janitor(foam: Foam) {\n  try {\n    const noOfFiles = foam.workspace.list().filter(Boolean).length;\n\n    if (noOfFiles === 0) {\n      return window.showInformationMessage(\n        \"Foam Janitor didn't find any notes to clean up.\"\n      );\n    }\n\n    const outcome = await window.withProgress(\n      {\n        location: ProgressLocation.Notification,\n        title: `Running Foam Janitor across ${noOfFiles} files!`,\n      },\n      () => runJanitor(foam)\n    );\n\n    if (!outcome.changedAnyFiles) {\n      window.showInformationMessage(\n        `Foam Janitor checked ${noOfFiles} files, and found nothing to clean up!`\n      );\n    } else {\n      window.showInformationMessage(\n        `Foam Janitor checked ${noOfFiles} files and updated ${outcome.updatedDefinitionListCount} out-of-date definition lists and added ${outcome.updatedHeadingCount} missing headings. Please check the changes before committing them into version control!`\n      );\n    }\n  } catch (e) {\n    window.showErrorMessage(\n      `Foam Janitor attempted to clean your workspace but ran into an error. Please check that we didn't break anything before committing any changes to version control, and pass the following error message to the Foam team on GitHub issues:\n    ${e.message}\n    ${e.stack}`\n    );\n  }\n}\n\nasync function runJanitor(foam: Foam) {\n  const notes: Resource[] = foam.workspace\n    .list()\n    .filter(r => r.uri.isMarkdown());\n\n  let updatedHeadingCount = 0;\n  let updatedDefinitionListCount = 0;\n\n  const dirtyTextDocuments = workspace.textDocuments.filter(\n    textDocument =>\n      (textDocument.languageId === 'markdown' ||\n        textDocument.languageId === 'mdx') &&\n      textDocument.isDirty\n  );\n\n  const dirtyEditorsFileName = dirtyTextDocuments.map(\n    dirtyTextDocument => dirtyTextDocument.uri.fsPath\n  );\n\n  const dirtyNotes = notes.filter(note =>\n    dirtyEditorsFileName.includes(note.uri.toFsPath())\n  );\n\n  const nonDirtyNotes = notes.filter(\n    note => !dirtyEditorsFileName.includes(note.uri.toFsPath())\n  );\n\n  const wikilinkSetting = getWikilinkDefinitionSetting();\n\n  // Apply Text Edits to Non Dirty Notes using fs module just like CLI\n\n  const fileWritePromises = nonDirtyNotes.map(async note => {\n    const noteText = await foam.workspace.readAsMarkdown(note.uri);\n    const noteEol = detectNewline(noteText);\n    const heading = await generateHeading(note, noteText, noteEol);\n    if (heading) {\n      updatedHeadingCount += 1;\n    }\n\n    const definitions =\n      wikilinkSetting === 'off'\n        ? []\n        : await generateLinkReferences(\n            note,\n            noteText,\n            noteEol,\n            foam.workspace,\n            wikilinkSetting === 'withExtensions'\n          );\n    if (definitions.length > 0) {\n      updatedDefinitionListCount += 1;\n    }\n\n    if (!heading && definitions.length === 0) {\n      return Promise.resolve();\n    }\n\n    // Apply Edits\n    // Note: The ordering matters. Definitions need to be inserted\n    // before heading, since inserting a heading changes line numbers below\n    let text = noteText;\n    text = definitions.length > 0 ? TextEdit.apply(text, definitions) : text;\n    text = heading ? TextEdit.apply(text, heading) : text;\n\n    return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));\n  });\n\n  await Promise.all(fileWritePromises);\n\n  // Handle dirty editors in serial, as VSCode only allows\n  // edits to be applied to active text editors\n  for (const doc of dirtyTextDocuments) {\n    const editor = await window.showTextDocument(doc);\n    const note = dirtyNotes.find(\n      n => n.uri.toFsPath() === editor.document.uri.fsPath\n    )!;\n\n    const noteText = doc.getText();\n    const eol = doc.eol.toString();\n    // Get edits\n    const heading = await generateHeading(note, noteText, eol);\n    const definitions =\n      wikilinkSetting === 'off'\n        ? []\n        : await generateLinkReferences(\n            note,\n            noteText,\n            eol,\n            foam.workspace,\n            wikilinkSetting === 'withExtensions'\n          );\n\n    if (heading || definitions.length > 0) {\n      // Apply Edits\n      /* eslint-disable */\n      await editor.edit(editBuilder => {\n        // Note: The ordering matters. Definitions need to be inserted\n        // before heading, since inserting a heading changes line numbers below\n        if (definitions.length > 0) {\n          updatedDefinitionListCount += 1;\n          // Apply all definition edits\n          definitions.forEach(definition => {\n            const start = definition.range.start;\n            const end = definition.range.end;\n\n            const range = Range.createFromPosition(start, end);\n            editBuilder.replace(toVsCodeRange(range), definition.newText);\n          });\n        }\n\n        if (heading) {\n          updatedHeadingCount += 1;\n          const start = heading.range.start;\n          editBuilder.replace(toVsCodePosition(start), heading.newText);\n        }\n      });\n      /* eslint-enable */\n    }\n  }\n\n  return {\n    updatedHeadingCount,\n    updatedDefinitionListCount,\n    changedAnyFiles: updatedHeadingCount + updatedDefinitionListCount,\n  };\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-daily-note-for-date.spec.ts",
    "content": "/* @unit-ready */\nimport dateFormat from 'dateformat';\nimport { commands, window } from 'vscode';\n\ndescribe('open-daily-note-for-date command', () => {\n  it('offers to pick which date to use', async () => {\n    const spy = jest\n      .spyOn(window, 'showQuickPick')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n\n    await commands.executeCommand('foam-vscode.open-daily-note-for-date');\n\n    expect(spy).toHaveBeenCalledWith(\n      expect.objectContaining([\n        expect.objectContaining({\n          label: expect.stringContaining(\n            dateFormat(new Date(), 'mmm dd, yyyy')\n          ),\n        }),\n      ]),\n      {\n        placeHolder: 'Choose or type a date (YYYY-MM-DD)',\n        matchOnDescription: true,\n        matchOnDetail: true,\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-daily-note-for-date.ts",
    "content": "import { ExtensionContext, commands, window, QuickPickItem } from 'vscode';\nimport { openDailyNoteFor } from '../../dated-notes';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { range } from 'lodash';\nimport dateFormat from 'dateformat';\nimport { Foam } from '../../core/model/foam';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand(\n      'foam-vscode.open-daily-note-for-date',\n      async () => {\n        const ws = (await foamPromise).workspace;\n        const date = await window\n          .showQuickPick<DateItem>(generateDateItems(ws), {\n            placeHolder: 'Choose or type a date (YYYY-MM-DD)',\n            matchOnDescription: true,\n            matchOnDetail: true,\n          })\n          .then(item => {\n            return item?.date;\n          });\n        return openDailyNoteFor(date, await foamPromise);\n      }\n    )\n  );\n}\n\nclass DateItem implements QuickPickItem {\n  public label: string;\n  public detail: string;\n  public description: string;\n  public alwaysShow?: boolean;\n  constructor(public date: Date, offset: number, public exists: boolean) {\n    const icon = exists ? '$(calendar)' : '$(new-file)';\n    this.label = `${icon} ${dateFormat(date, 'mmm dd, yyyy')}`;\n    this.detail = dateFormat(date, 'dddd');\n    if (offset === 0) {\n      this.detail = 'Today';\n    } else if (offset === -1) {\n      this.detail = 'Yesterday';\n    } else if (offset === 1) {\n      this.detail = 'Tomorrow';\n    } else if (offset > -8 && offset < -1) {\n      this.detail = `Last ${dateFormat(date, 'dddd')}`;\n    } else if (offset > 1 && offset < 8) {\n      this.detail = `Next ${dateFormat(date, 'dddd')}`;\n    }\n  }\n}\n\nfunction generateDateItems(ws: FoamWorkspace): DateItem[] {\n  const items = [\n    ...range(0, 32), // next month\n    ...range(-31, 0), // last month\n  ].map(offset => {\n    const date = new Date();\n    date.setDate(date.getDate() + offset);\n    // TODO this is only compatible with default settings as it would\n    // be otherwise hard to \"guess\" the daily note path\n    // Ideally we would read the daily note path from the config or template to properly match\n    const noteBasename = dateFormat(date, 'yyyy-mm-dd', false);\n    const exists = ws.find(noteBasename) ? true : false;\n    return new DateItem(date, offset, exists);\n  });\n\n  return items;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-daily-note.ts",
    "content": "import { ExtensionContext, commands } from 'vscode';\nimport { getFoamVsCodeConfig } from '../../services/config';\nimport { openDailyNoteFor } from '../../dated-notes';\nimport { Foam } from '../../core/model/foam';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.open-daily-note', async () =>\n      openDailyNoteFor(new Date(), await foamPromise)\n    )\n  );\n\n  if (getFoamVsCodeConfig('openDailyNote.onStartup', false)) {\n    commands.executeCommand('foam-vscode.open-daily-note');\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-dated-note.ts",
    "content": "import { ExtensionContext, commands } from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { getFoamVsCodeConfig } from '../../services/config';\nimport {\n  createDailyNoteIfNotExists,\n  openDailyNoteFor,\n} from '../../dated-notes';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.open-dated-note', async date => {\n      const foam = await foamPromise;\n      switch (getFoamVsCodeConfig('dateSnippets.afterCompletion')) {\n        case 'navigateToNote':\n          return openDailyNoteFor(date, foam);\n        case 'createNote':\n          return createDailyNoteIfNotExists(date, foam);\n      }\n    })\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-random-note.ts",
    "content": "import { ExtensionContext, commands, window } from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { focusNote } from '../../services/editor';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.open-random-note', async () => {\n      const foam = await foamPromise;\n      const currentFile = window.activeTextEditor?.document.uri.path;\n      const notes = foam.workspace.list().filter(r => r.uri.isMarkdown());\n      if (notes.length <= 1) {\n        window.showInformationMessage(\n          'Could not find another note to open. If you believe this is a bug, please file an issue.'\n        );\n        return;\n      }\n\n      let randomNoteIndex = Math.floor(Math.random() * notes.length);\n      if (notes[randomNoteIndex].uri.path === currentFile) {\n        randomNoteIndex = (randomNoteIndex + 1) % notes.length;\n      }\n\n      focusNote(notes[randomNoteIndex].uri, false);\n    })\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-resource.spec.ts",
    "content": "import { commands, window } from 'vscode';\nimport { CommandDescriptor } from '../../utils/commands';\nimport { OpenResourceArgs, OPEN_COMMAND } from './open-resource';\nimport { URI } from '../../core/model/uri';\nimport {\n  closeEditors,\n  createFile,\n  waitForNoteInFoamWorkspace,\n} from '../../test/test-utils-vscode';\nimport { deleteFile } from '../../services/editor';\nimport waitForExpect from 'wait-for-expect';\n\ndescribe('open-resource command', () => {\n  beforeEach(async () => {\n    jest.resetAllMocks();\n    await closeEditors();\n  });\n\n  afterEach(async () => {\n    await closeEditors();\n  });\n\n  it('URI param has precedence over filter', async () => {\n    const noteA = await createFile('Note A for open command');\n    await waitForNoteInFoamWorkspace(noteA.uri);\n\n    const command: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        uri: noteA.uri,\n        filter: { title: 'note 1' },\n      },\n    };\n    await commands.executeCommand(command.name, command.params);\n\n    await waitForExpect(() => {\n      expect(window.activeTextEditor).toBeTruthy();\n      expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);\n    });\n\n    await deleteFile(noteA.uri);\n  });\n\n  it('URI param accept URI object, or path', async () => {\n    const noteA = await createFile('Note A for open command');\n    await waitForNoteInFoamWorkspace(noteA.uri);\n\n    const uriCommand: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        uri: noteA.uri,\n      },\n    };\n    await commands.executeCommand(uriCommand.name, uriCommand.params);\n    await waitForExpect(() => {\n      expect(window.activeTextEditor).toBeTruthy();\n      expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);\n    });\n\n    await closeEditors();\n\n    const pathCommand: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        uri: noteA.uri.path,\n      },\n    };\n    await commands.executeCommand(pathCommand.name, pathCommand.params);\n    await waitForExpect(() => {\n      expect(window.activeTextEditor).toBeTruthy();\n      expect(window.activeTextEditor.document.uri.path).toEqual(noteA.uri.path);\n    });\n    await deleteFile(noteA.uri);\n  });\n\n  it('User is notified if no resource is found with filter', async () => {\n    const spy = jest.spyOn(window, 'showInformationMessage');\n\n    const command: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        filter: { title: 'note 1 with no existing title' },\n      },\n    };\n    await commands.executeCommand(command.name, command.params);\n\n    await waitForExpect(() => {\n      expect(spy).toHaveBeenCalled();\n    });\n  });\n\n  it('User is notified if no resource is found with URI', async () => {\n    const spy = jest.spyOn(window, 'showInformationMessage');\n\n    const command: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        uri: URI.file('path/to/nonexistent.md'),\n      },\n    };\n    await commands.executeCommand(command.name, command.params);\n\n    await waitForExpect(() => {\n      expect(spy).toHaveBeenCalled();\n    });\n  });\n\n  it('filter with multiple results will show a quick pick', async () => {\n    const noteA = await createFile('Note A for filter test');\n    const noteB = await createFile('Note B for filter test');\n    await waitForNoteInFoamWorkspace(noteA.uri);\n    await waitForNoteInFoamWorkspace(noteB.uri);\n\n    const spy = jest\n      .spyOn(window, 'showQuickPick')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(undefined)));\n\n    const command: CommandDescriptor<OpenResourceArgs> = {\n      name: OPEN_COMMAND.command,\n      params: {\n        filter: { title: '.*' },\n      },\n    };\n    await commands.executeCommand(command.name, command.params);\n\n    await waitForExpect(() => {\n      expect(spy).toHaveBeenCalled();\n    });\n\n    await deleteFile(noteA.uri);\n    await deleteFile(noteB.uri);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/open-resource.ts",
    "content": "import * as vscode from 'vscode';\nimport { URI } from '../../core/model/uri';\nimport { toVsCodeUri } from '../../utils/vsc-utils';\nimport { Foam } from '../../core/model/foam';\nimport { QueryFilter, parseFilter } from '../../core/query';\nimport { CommandDescriptor } from '../../utils/commands';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FoamGraph } from '../../core/model/graph';\nimport { Resource } from '../../core/model/note';\nimport { isNone } from '../../core/utils';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  context.subscriptions.push(\n    vscode.commands.registerCommand(OPEN_COMMAND.command, args => {\n      return openResource(foam.workspace, foam.graph, args);\n    })\n  );\n}\n\nexport interface OpenResourceArgs {\n  /**\n   * The URI of the resource to open.\n   * If present the `filter` param is ignored\n   */\n  uri?: URI | string | vscode.Uri;\n\n  /**\n   * The filter object that describes which notes to consider\n   * for opening\n   */\n  filter?: QueryFilter;\n}\n\nexport const OPEN_COMMAND = {\n  command: 'foam-vscode.open-resource',\n  title: 'Foam: Open Resource',\n\n  forURI: (uri: URI): CommandDescriptor<OpenResourceArgs> => {\n    return {\n      name: OPEN_COMMAND.command,\n      params: {\n        uri: uri,\n      },\n    };\n  },\n};\n\nasync function openResource(\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  args?: OpenResourceArgs\n) {\n  args = args ?? {};\n\n  let item: { uri: URI } | null = null;\n\n  if (args.uri) {\n    const path = typeof args.uri === 'string' ? args.uri : args.uri.path;\n    item = workspace.find(path);\n  }\n\n  if (isNone(item) && args.filter) {\n    const predicate = parseFilter(\n      args.filter,\n      workspace,\n      graph,\n      vscode.workspace.isTrusted\n    );\n    const candidates = workspace.list().filter(predicate);\n\n    if (candidates.length === 0) {\n      vscode.window.showInformationMessage(\n        'Foam: No note matches given filters.'\n      );\n      return;\n    }\n\n    item =\n      candidates.length === 1\n        ? candidates[0]\n        : await vscode.window.showQuickPick(\n            candidates.map(createQuickPickItemForResource)\n          );\n  }\n\n  if (isNone(item)) {\n    vscode.window.showInformationMessage(\n      'Foam: No note matches given filters or URI.'\n    );\n    return;\n  }\n\n  const targetUri =\n    item.uri.path === vscode.window.activeTextEditor?.document.uri.path\n      ? vscode.window.activeTextEditor?.document.uri\n      : toVsCodeUri(item.uri.asPlain());\n  return vscode.commands.executeCommand('vscode.open', targetUri);\n}\n\ninterface ResourceItem extends vscode.QuickPickItem {\n  label: string;\n  description: string;\n  uri: URI;\n  detail?: string;\n}\n\nconst createQuickPickItemForResource = (resource: Resource): ResourceItem => {\n  const icon = 'file';\n  const sections = resource.sections\n    .map(s => s.label)\n    .filter(l => l !== resource.title);\n  const detail = sections.length > 0 ? 'Sections: ' + sections.join(', ') : '';\n  return {\n    label: `$(${icon}) ${resource.title}`,\n    description: vscode.workspace.asRelativePath(resource.uri.toFsPath()),\n    uri: resource.uri,\n    detail: detail,\n  };\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/rename-tag.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { TagEdit } from '../../core/services/tag-edit';\nimport { TagItem } from '../panels/tags-explorer';\nimport { fromVsCodeUri, toVsCodeWorkspaceEdit } from '../../utils/vsc-utils';\nimport { Logger } from '../../core/utils/log';\nimport { Position } from '../../core/model/position';\n\n/**\n * Command definition for the tag rename functionality.\n *\n * This command provides workspace-wide tag renaming capabilities with multiple\n * invocation methods: command palette, context menus, and programmatic calls.\n */\nexport const RENAME_TAG_COMMAND = {\n  /** VS Code command identifier */\n  command: 'foam-vscode.rename-tag',\n  /** Display name shown in command palette */\n  title: 'Foam: Rename Tag',\n};\n\n/**\n * Activates the rename tag command feature.\n *\n * Registers the rename tag command with VS Code and sets up error handling.\n * The command supports multiple parameter combinations for different use cases.\n *\n * @param context VS Code extension context for registering disposables\n * @param foamPromise Promise that resolves to the initialized Foam instance\n */\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    vscode.commands.registerCommand(\n      RENAME_TAG_COMMAND.command,\n      async (tagLabelOrItem?: string | TagItem, newTagName?: string) => {\n        try {\n          await executeRenameTag(foam, tagLabelOrItem, newTagName);\n        } catch (error) {\n          Logger.error('Error executing rename tag command:', error);\n          vscode.window.showErrorMessage(\n            `Failed to rename tag: ${error.message}`\n          );\n        }\n      }\n    )\n  );\n}\n\n/**\n * Execute the tag rename operation with flexible parameter handling.\n *\n * This function handles the complete tag rename workflow:\n * 1. Determine which tag to rename (from parameters, cursor position, or user selection)\n * 2. Get the new tag name (from parameters or user input)\n * 3. Validate the rename operation\n * 4. Apply the changes across the workspace\n *\n * @param foam The Foam instance containing workspace and tag information\n * @param tagLabelOrItem Optional tag to rename (string label or TagItem from explorer)\n * @param newTagName Optional new name for the tag\n *\n * @example\n * ```typescript\n * // Rename specific tag programmatically\n * await executeRenameTag(foam, 'oldtag', 'newtag');\n *\n * // Interactive rename with tag picker\n * await executeRenameTag(foam);\n *\n * // Rename tag from Tags Explorer context\n * await executeRenameTag(foam, tagItem);\n * ```\n */\nasync function executeRenameTag(\n  foam: Foam,\n  tagLabelOrItem?: string | TagItem,\n  newTagName?: string\n): Promise<void> {\n  let tagLabel: string | undefined;\n\n  // Determine the tag to rename\n  if (typeof tagLabelOrItem === 'string') {\n    tagLabel = tagLabelOrItem;\n  } else if (\n    tagLabelOrItem &&\n    typeof tagLabelOrItem === 'object' &&\n    'tag' in tagLabelOrItem\n  ) {\n    tagLabel = tagLabelOrItem.tag;\n  } else {\n    // Try to detect tag from current cursor position\n    const activeEditor = vscode.window.activeTextEditor;\n    if (activeEditor && activeEditor.document.languageId === 'markdown') {\n      const vsPosition = activeEditor.selection.active;\n      const fileUri = fromVsCodeUri(activeEditor.document.uri);\n      const position = Position.create(vsPosition.line, vsPosition.character);\n\n      tagLabel = TagEdit.getTagAtPosition(foam.tags, fileUri, position);\n    }\n  }\n\n  // If we still don't have a tag, show picker\n  if (!tagLabel) {\n    const allTags = Array.from(foam.tags.tags.keys()).sort();\n\n    if (allTags.length === 0) {\n      vscode.window.showInformationMessage('No tags found in workspace.');\n      return;\n    }\n\n    tagLabel = await vscode.window.showQuickPick(allTags, {\n      title: 'Select a tag to rename',\n      placeHolder: 'Choose a tag to rename...',\n    });\n\n    if (!tagLabel) {\n      return; // User cancelled\n    }\n  }\n\n  // Get the new tag name from user or use provided parameter\n  let finalNewTagName = newTagName;\n\n  // If newTagName was provided, validate it first\n  if (finalNewTagName) {\n    const cleanValue = finalNewTagName.startsWith('#')\n      ? finalNewTagName.substring(1)\n      : finalNewTagName;\n\n    const validation = TagEdit.validateTagRename(\n      foam.tags,\n      tagLabel!,\n      cleanValue\n    );\n    if (!validation.isValid) {\n      throw new Error(validation.message);\n    }\n\n    // Handle merge confirmation if needed\n    if (validation.isMerge) {\n      const confirmed = await vscode.window.showWarningMessage(\n        `Tag \"${cleanValue}\" already exists (${\n          validation.targetOccurrences\n        } occurrence${\n          validation.targetOccurrences !== 1 ? 's' : ''\n        }). Merge \"${tagLabel}\" (${validation.sourceOccurrences} occurrence${\n          validation.sourceOccurrences !== 1 ? 's' : ''\n        }) into it?`,\n        { modal: true },\n        'Merge Tags'\n      );\n\n      if (confirmed !== 'Merge Tags') {\n        throw new Error('Tag merge cancelled by user');\n      }\n    }\n  }\n\n  if (!finalNewTagName) {\n    const currentOccurrences = foam.tags.tags.get(tagLabel)?.length ?? 0;\n    finalNewTagName = await vscode.window.showInputBox({\n      title: `Rename tag \"${tagLabel}\"`,\n      prompt: `Enter new name for tag \"${tagLabel}\" (${currentOccurrences} occurrence${\n        currentOccurrences !== 1 ? 's' : ''\n      })`,\n      value: tagLabel,\n      validateInput: (value: string) => {\n        const validation = TagEdit.validateTagRename(\n          foam.tags,\n          tagLabel!,\n          value\n        );\n\n        if (!validation.isValid) {\n          return validation.message;\n        }\n\n        // Show merge information but allow the input\n        if (validation.isMerge) {\n          return {\n            message: `Will merge into existing tag: ${value} - ${\n              validation.targetOccurrences\n            } occurrence${validation.targetOccurrences !== 1 ? 's' : ''}`,\n            severity: vscode.InputBoxValidationSeverity.Info,\n          };\n        }\n\n        return undefined;\n      },\n    });\n\n    if (!finalNewTagName) {\n      return; // User cancelled\n    }\n  }\n\n  // Clean the new name\n  const cleanNewName = finalNewTagName.startsWith('#')\n    ? finalNewTagName.substring(1)\n    : finalNewTagName;\n\n  // Final validation and merge confirmation for input box flow\n  const finalValidation = TagEdit.validateTagRename(\n    foam.tags,\n    tagLabel,\n    cleanNewName\n  );\n\n  if (!finalValidation.isValid) {\n    throw new Error(finalValidation.message);\n  }\n\n  // Check for child tags and offer hierarchical rename\n  const childTags = TagEdit.findChildTags(foam.tags, tagLabel);\n  const hasChildren = childTags.length > 0;\n  let useHierarchicalRename = false;\n\n  if (hasChildren) {\n    const childList = childTags.map(tag => `• ${tag}`).join('\\n');\n    const choice = await vscode.window.showWarningMessage(\n      `Tag \"${tagLabel}\" has ${childTags.length} child tag${\n        childTags.length !== 1 ? 's' : ''\n      }:\\n\\n${childList}\\n\\nHow would you like to proceed?`,\n      { modal: true },\n      'Rename Only Parent',\n      'Rename All (Parent + Children)'\n    );\n\n    if (choice === 'Rename All (Parent + Children)') {\n      useHierarchicalRename = true;\n    } else if (choice !== 'Rename Only Parent') {\n      return; // User cancelled\n    }\n  }\n\n  // Handle merge confirmation if needed (for input box flow)\n  if (finalValidation.isMerge) {\n    const confirmed = await vscode.window.showWarningMessage(\n      `Tag \"${cleanNewName}\" already exists (${\n        finalValidation.targetOccurrences\n      } occurrence${\n        finalValidation.targetOccurrences !== 1 ? 's' : ''\n      }). Merge \"${tagLabel}\" (${finalValidation.sourceOccurrences} occurrence${\n        finalValidation.sourceOccurrences !== 1 ? 's' : ''\n      }) into it?`,\n      { modal: true },\n      'Merge Tags'\n    );\n\n    if (confirmed !== 'Merge Tags') {\n      return; // User cancelled merge\n    }\n  }\n\n  // Perform the rename\n  await performTagRename(foam, tagLabel, cleanNewName, useHierarchicalRename);\n}\n\n/**\n * Perform the actual tag rename operation by applying workspace edits.\n *\n * This internal function generates all necessary text edits and applies them\n * to the workspace. It provides user feedback through VS Code notifications\n * and logs the operation results.\n *\n * @param foam The Foam instance containing workspace and tag information\n * @param oldTagLabel The current tag label to be renamed\n * @param newTagLabel The new tag label to rename to\n * @param useHierarchicalRename Whether to rename child tags as well\n * @throws Error if workspace edits cannot be applied\n * @internal\n */\nasync function performTagRename(\n  foam: Foam,\n  oldTagLabel: string,\n  newTagLabel: string,\n  useHierarchicalRename: boolean = false\n): Promise<void> {\n  // Generate all the edits - use hierarchical method if requested\n  const tagEditResult = useHierarchicalRename\n    ? TagEdit.createHierarchicalRenameEdits(foam.tags, oldTagLabel, newTagLabel)\n    : TagEdit.createRenameTagEdits(foam.tags, oldTagLabel, newTagLabel);\n\n  if (tagEditResult.totalOccurrences === 0) {\n    vscode.window.showWarningMessage(\n      `No occurrences of tag \"${oldTagLabel}\" found.`\n    );\n    return;\n  }\n\n  // Convert to VS Code WorkspaceEdit\n  const workspaceEdit = toVsCodeWorkspaceEdit(\n    tagEditResult.edits,\n    foam.workspace\n  );\n\n  // Apply the edits\n  const success = await vscode.workspace.applyEdit(workspaceEdit);\n\n  if (success) {\n    // Calculate unique file count from workspace edits\n    const uniqueFiles = new Set(\n      tagEditResult.edits.map(edit => edit.uri.toString())\n    ).size;\n    const occurrences = tagEditResult.totalOccurrences;\n\n    Logger.info(\n      `Successfully renamed tag \"${oldTagLabel}\" to \"${newTagLabel}\" (${occurrences} occurrences across ${uniqueFiles} files)`\n    );\n\n    vscode.window.showInformationMessage(\n      `Renamed tag \"${oldTagLabel}\" to \"${newTagLabel}\" (${occurrences} occurrence${\n        occurrences !== 1 ? 's' : ''\n      } across ${uniqueFiles} file${uniqueFiles !== 1 ? 's' : ''})`\n    );\n  } else {\n    throw new Error('Failed to apply workspace edits');\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/search-tag.spec.ts",
    "content": "/* @unit-ready */\nimport { generateTagSearchPattern } from './search-tag';\n\ndescribe('search-tag command', () => {\n  describe('generateTagSearchPattern', () => {\n    it('generates correct regex pattern for simple tag', () => {\n      const pattern = generateTagSearchPattern('project-alpha');\n      expect(pattern).toBe(\n        '#project-alpha\\\\b|tags:.*?\\\\bproject-alpha\\\\b|^\\\\s*-\\\\s+project-alpha\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes special regex characters in tag label', () => {\n      const pattern = generateTagSearchPattern('tag.with+special*chars');\n      expect(pattern).toBe(\n        '#tag\\\\.with\\\\+special\\\\*chars\\\\b|tags:.*?\\\\btag\\\\.with\\\\+special\\\\*chars\\\\b|^\\\\s*-\\\\s+tag\\\\.with\\\\+special\\\\*chars\\\\b\\\\s*$'\n      );\n    });\n\n    it('handles hierarchical tags with forward slashes', () => {\n      const pattern = generateTagSearchPattern('status/active');\n      // Forward slashes don't need escaping in regex\n      expect(pattern).toBe(\n        '#status/active\\\\b|tags:.*?\\\\bstatus/active\\\\b|^\\\\s*-\\\\s+status/active\\\\b\\\\s*$'\n      );\n    });\n\n    it('handles tags with hyphens', () => {\n      const pattern = generateTagSearchPattern('v1-release');\n      expect(pattern).toBe(\n        '#v1-release\\\\b|tags:.*?\\\\bv1-release\\\\b|^\\\\s*-\\\\s+v1-release\\\\b\\\\s*$'\n      );\n    });\n\n    it('handles tags with underscores', () => {\n      const pattern = generateTagSearchPattern('my_tag');\n      expect(pattern).toBe(\n        '#my_tag\\\\b|tags:.*?\\\\bmy_tag\\\\b|^\\\\s*-\\\\s+my_tag\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes parentheses', () => {\n      const pattern = generateTagSearchPattern('tag(with)parens');\n      expect(pattern).toBe(\n        '#tag\\\\(with\\\\)parens\\\\b|tags:.*?\\\\btag\\\\(with\\\\)parens\\\\b|^\\\\s*-\\\\s+tag\\\\(with\\\\)parens\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes square brackets', () => {\n      const pattern = generateTagSearchPattern('tag[with]brackets');\n      expect(pattern).toBe(\n        '#tag\\\\[with\\\\]brackets\\\\b|tags:.*?\\\\btag\\\\[with\\\\]brackets\\\\b|^\\\\s*-\\\\s+tag\\\\[with\\\\]brackets\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes curly braces', () => {\n      const pattern = generateTagSearchPattern('tag{with}braces');\n      expect(pattern).toBe(\n        '#tag\\\\{with\\\\}braces\\\\b|tags:.*?\\\\btag\\\\{with\\\\}braces\\\\b|^\\\\s*-\\\\s+tag\\\\{with\\\\}braces\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes question marks', () => {\n      const pattern = generateTagSearchPattern('tag?');\n      expect(pattern).toBe(\n        '#tag\\\\?\\\\b|tags:.*?\\\\btag\\\\?\\\\b|^\\\\s*-\\\\s+tag\\\\?\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes dollar signs', () => {\n      const pattern = generateTagSearchPattern('tag$');\n      expect(pattern).toBe(\n        '#tag\\\\$\\\\b|tags:.*?\\\\btag\\\\$\\\\b|^\\\\s*-\\\\s+tag\\\\$\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes caret', () => {\n      const pattern = generateTagSearchPattern('^tag');\n      expect(pattern).toBe(\n        '#\\\\^tag\\\\b|tags:.*?\\\\b\\\\^tag\\\\b|^\\\\s*-\\\\s+\\\\^tag\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes pipe', () => {\n      const pattern = generateTagSearchPattern('tag|other');\n      expect(pattern).toBe(\n        '#tag\\\\|other\\\\b|tags:.*?\\\\btag\\\\|other\\\\b|^\\\\s*-\\\\s+tag\\\\|other\\\\b\\\\s*$'\n      );\n    });\n\n    it('escapes backslash', () => {\n      const pattern = generateTagSearchPattern('tag\\\\test');\n      expect(pattern).toBe(\n        '#tag\\\\\\\\test\\\\b|tags:.*?\\\\btag\\\\\\\\test\\\\b|^\\\\s*-\\\\s+tag\\\\\\\\test\\\\b\\\\s*$'\n      );\n    });\n  });\n\n  describe('pattern matching verification', () => {\n    it('pattern should match inline hashtags', () => {\n      const pattern = generateTagSearchPattern('test-tag');\n      const regex = new RegExp(pattern);\n\n      expect(regex.test('#test-tag')).toBe(true);\n      expect(regex.test('#test-tag in sentence')).toBe(true);\n      expect(regex.test('text #test-tag more text')).toBe(true);\n    });\n\n    it('pattern should match YAML array format', () => {\n      const pattern = generateTagSearchPattern('test-tag');\n      const regex = new RegExp(pattern);\n\n      expect(regex.test('tags: [test-tag, other]')).toBe(true);\n      expect(regex.test('tags: [test-tag]')).toBe(true);\n      expect(regex.test('tags: [other, test-tag]')).toBe(true);\n      expect(regex.test('tags: test-tag')).toBe(true);\n    });\n\n    it('pattern should match YAML list format with tag right after dash', () => {\n      const pattern = generateTagSearchPattern('test-tag');\n      const regex = new RegExp(pattern, 'm'); // multiline flag\n\n      expect(regex.test('  - test-tag')).toBe(true);\n      expect(regex.test('- test-tag')).toBe(true);\n      expect(regex.test('    - test-tag')).toBe(true);\n    });\n\n    it('pattern should NOT match markdown lists with tag not right after dash', () => {\n      const pattern = generateTagSearchPattern('test-tag');\n      const regex = new RegExp(pattern, 'm');\n\n      // These should NOT match because the tag is not immediately after the dash\n      expect(regex.test('- This is a test-tag item')).toBe(false);\n      expect(regex.test('- Some text about test-tag')).toBe(false);\n      expect(regex.test('  - Another test-tag mention')).toBe(false);\n    });\n\n    it('pattern should NOT match list items with tag followed by other text', () => {\n      const pattern = generateTagSearchPattern('javascript');\n      const regex = new RegExp(pattern, 'm');\n\n      // These should NOT match because there's text after the tag\n      expect(regex.test('- javascript is cool')).toBe(false);\n      expect(regex.test('  - javascript programming')).toBe(false);\n      expect(regex.test('- javascript: the language')).toBe(false);\n    });\n\n    it('pattern should not match partial words', () => {\n      const pattern = generateTagSearchPattern('test');\n      const regex = new RegExp(pattern);\n\n      expect(regex.test('#testing')).toBe(false);\n      expect(regex.test('tags: [testing]')).toBe(false);\n      expect(regex.test('- testing')).toBe(false);\n    });\n\n    it('pattern should match hierarchical tags', () => {\n      const pattern = generateTagSearchPattern('project/alpha');\n      const regex = new RegExp(pattern, 'm');\n\n      expect(regex.test('#project/alpha')).toBe(true);\n      expect(regex.test('tags: [project/alpha]')).toBe(true);\n      expect(regex.test('  - project/alpha')).toBe(true);\n    });\n\n    it('pattern should match tags with spaces after dash', () => {\n      const pattern = generateTagSearchPattern('my-tag');\n      const regex = new RegExp(pattern, 'm');\n\n      // Multiple spaces after dash should still match\n      expect(regex.test('  -   my-tag')).toBe(true);\n      expect(regex.test('-  my-tag')).toBe(true);\n    });\n\n    it('pattern should handle real-world YAML examples', () => {\n      const pattern = generateTagSearchPattern('javascript');\n      const regex = new RegExp(pattern, 'm');\n\n      // YAML front matter examples - should match\n      const yamlArray = 'tags: [javascript, typescript, react]';\n      const yamlList = '  - javascript';\n      const yamlListWithTrailingSpace = '  - javascript  ';\n      const yamlSingle = 'tags: javascript';\n      const inline = 'Learn #javascript today';\n\n      expect(regex.test(yamlArray)).toBe(true);\n      expect(regex.test(yamlList)).toBe(true);\n      expect(regex.test(yamlListWithTrailingSpace)).toBe(true);\n      expect(regex.test(yamlSingle)).toBe(true);\n      expect(regex.test(inline)).toBe(true);\n\n      // False positives - should NOT match\n      const falsePositive1 = '- Learn javascript programming';\n      const falsePositive2 = '- javascript is cool';\n      const falsePositive3 = '  - javascript tutorial';\n\n      expect(regex.test(falsePositive1)).toBe(false);\n      expect(regex.test(falsePositive2)).toBe(false);\n      expect(regex.test(falsePositive3)).toBe(false);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/search-tag.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { TagItem } from '../panels/tags-explorer';\n\nexport const SEARCH_TAG_COMMAND = {\n  command: 'foam-vscode.search-tag',\n  title: 'Foam: Search Tag',\n};\n\n/**\n * Generates a regex search pattern that matches both inline tags (#tag) and YAML front matter tags.\n *\n * @param tagLabel The tag label to search for (without # prefix)\n * @returns A regex pattern string that matches the tag in both formats\n */\nexport function generateTagSearchPattern(tagLabel: string): string {\n  // Escape special regex characters in tag label\n  const escapedTag = tagLabel.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&');\n\n  // Pattern matches three cases:\n  // 1. #tag - inline hashtags with word boundary\n  // 2. tags: [...tag...] - YAML front matter array format\n  // 3. ^\\s*-\\s+tag\\s*$ - YAML front matter list format (tag is the only content after dash)\n  return `#${escapedTag}\\\\b|tags:.*?\\\\b${escapedTag}\\\\b|^\\\\s*-\\\\s+${escapedTag}\\\\b\\\\s*$`;\n}\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    vscode.commands.registerCommand(\n      SEARCH_TAG_COMMAND.command,\n      async (tagLabelOrItem?: string | TagItem) => {\n        let tagLabel: string | undefined;\n\n        // Handle both string and TagItem parameters\n        if (typeof tagLabelOrItem === 'string') {\n          tagLabel = tagLabelOrItem;\n        } else if (\n          tagLabelOrItem &&\n          typeof tagLabelOrItem === 'object' &&\n          'tag' in tagLabelOrItem\n        ) {\n          tagLabel = tagLabelOrItem.tag;\n        }\n\n        if (!tagLabel) {\n          // If no tag provided, show tag picker\n          const allTags = Array.from(foam.tags.tags.keys()).sort();\n          if (allTags.length === 0) {\n            vscode.window.showInformationMessage('No tags found in workspace.');\n            return;\n          }\n\n          tagLabel = await vscode.window.showQuickPick(allTags, {\n            title: 'Select a tag to search',\n            placeHolder: 'Choose a tag to search for...',\n          });\n\n          if (!tagLabel) {\n            return; // User cancelled\n          }\n        }\n\n        // Generate search pattern that matches both inline and YAML tags\n        const searchPattern = generateTagSearchPattern(tagLabel);\n\n        await vscode.commands.executeCommand('workbench.action.findInFiles', {\n          query: searchPattern,\n          triggerSearch: true,\n          matchWholeWord: false,\n          isCaseSensitive: true,\n          isRegex: true,\n        });\n      }\n    )\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/update-graph.ts",
    "content": "import { commands, ExtensionContext } from 'vscode';\nimport { Foam } from '../../core/model/foam';\n\nexport const UPDATE_GRAPH_COMMAND_NAME = 'foam-vscode.update-graph';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  context.subscriptions.push(\n    commands.registerCommand(UPDATE_GRAPH_COMMAND_NAME, async () => {\n      const foam = await foamPromise;\n      return foam.graph.update();\n    })\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/commands/update-wikilinks.ts",
    "content": "import {\n  CancellationToken,\n  CodeLens,\n  CodeLensProvider,\n  commands,\n  ExtensionContext,\n  languages,\n  Range,\n  TextDocument,\n  window,\n  workspace,\n  Position,\n} from 'vscode';\nimport { isMdEditor, getFoamDocSelectors } from '../../services/editor';\nimport { Foam } from '../../core/model/foam';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport {\n  LINK_REFERENCE_DEFINITION_FOOTER,\n  LINK_REFERENCE_DEFINITION_HEADER,\n  generateLinkReferences,\n} from '../../core/janitor/generate-link-references';\nimport { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';\nimport { getEditorEOL } from '../../services/editor';\nimport { ResourceParser } from '../../core/model/note';\nimport { IMatcher } from '../../core/services/datastore';\n\nexport default async function activate(\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.update-wikilink-definitions', () => {\n      return updateWikilinkDefinitions(\n        foam.workspace,\n        foam.services.parser,\n        foam.services.matcher\n      );\n    }),\n    workspace.onWillSaveTextDocument(e => {\n      e.waitUntil(\n        updateWikilinkDefinitions(\n          foam.workspace,\n          foam.services.parser,\n          foam.services.matcher\n        )\n      );\n    }),\n    languages.registerCodeLensProvider(\n      getFoamDocSelectors(),\n      new WikilinkReferenceCodeLensProvider(\n        foam.workspace,\n        foam.services.parser\n      )\n    )\n  );\n}\n\nexport function getWikilinkDefinitionSetting():\n  | 'withExtensions'\n  | 'withoutExtensions'\n  | 'off' {\n  return workspace\n    .getConfiguration('foam.edit')\n    .get('linkReferenceDefinitions', 'withoutExtensions');\n}\n\nasync function updateWikilinkDefinitions(\n  fWorkspace: FoamWorkspace,\n  fParser: ResourceParser,\n  fMatcher: IMatcher\n) {\n  const editor = window.activeTextEditor;\n  const doc = editor.document;\n\n  if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {\n    return;\n  }\n\n  const setting = getWikilinkDefinitionSetting();\n  const eol = getEditorEOL();\n  const text = doc.getText();\n\n  if (setting === 'off') {\n    const { range } = detectDocumentWikilinkDefinitions(text, eol);\n    if (range) {\n      await editor.edit(editBuilder => {\n        editBuilder.delete(toVsCodeRange(range));\n      });\n    }\n    return;\n  }\n\n  const resource = fParser.parse(fromVsCodeUri(doc.uri), text);\n  const updates = await generateLinkReferences(\n    resource,\n    text,\n    eol,\n    fWorkspace,\n    setting === 'withExtensions'\n  );\n\n  if (updates.length > 0) {\n    await editor.edit(editBuilder => {\n      updates.forEach(update => {\n        const gap = doc.lineAt(update.range.start.line - 1).isEmptyOrWhitespace\n          ? ''\n          : eol;\n        editBuilder.replace(toVsCodeRange(update.range), gap + update.newText);\n      });\n    });\n  }\n}\n\n/**\n * Detects the range of the wikilink definitions in the document.\n */\nfunction detectDocumentWikilinkDefinitions(text: string, eol: string) {\n  const lines = text.split(eol);\n\n  const headerLine = lines.findIndex(\n    line => line === LINK_REFERENCE_DEFINITION_HEADER\n  );\n  const footerLine = lines.findIndex(\n    line => line === LINK_REFERENCE_DEFINITION_FOOTER\n  );\n\n  if (headerLine < 0 || footerLine < 0 || headerLine >= footerLine) {\n    return { range: null, definitions: null };\n  }\n\n  const range = new Range(\n    new Position(headerLine, 0),\n    new Position(footerLine, lines[footerLine].length)\n  );\n  const definitions = lines.slice(headerLine, footerLine).join(eol);\n\n  return { range, definitions };\n}\n\n/**\n * Provides a code lens to update the wikilink definitions in the document.\n */\nclass WikilinkReferenceCodeLensProvider implements CodeLensProvider {\n  constructor(\n    private fWorkspace: FoamWorkspace,\n    private fParser: ResourceParser\n  ) {}\n\n  public async provideCodeLenses(\n    document: TextDocument,\n    _: CancellationToken\n  ): Promise<CodeLens[]> {\n    const eol = getEditorEOL();\n    const text = document.getText();\n\n    const { range } = detectDocumentWikilinkDefinitions(text, eol);\n    if (!range) {\n      return [];\n    }\n    const setting = getWikilinkDefinitionSetting();\n\n    const resource = this.fParser.parse(fromVsCodeUri(document.uri), text);\n    const update = await generateLinkReferences(\n      resource,\n      text,\n      eol,\n      this.fWorkspace,\n      setting === 'withExtensions'\n    );\n\n    const status = update == null ? 'up to date' : 'out of date';\n\n    return [\n      new CodeLens(range, {\n        command:\n          update == null ? '' : 'foam-vscode.update-wikilink-definitions',\n        title: `Wikilink definitions (${status})`,\n        arguments: [],\n      }),\n    ];\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/date-snippets.ts",
    "content": "import {\n  ExtensionContext,\n  languages,\n  CompletionItemProvider,\n  CompletionItem,\n  CompletionItemKind,\n  CompletionList,\n  CompletionTriggerKind,\n} from 'vscode';\nimport { getDailyNoteFileName } from '../dated-notes';\nimport { getFoamVsCodeConfig } from '../services/config';\n\nexport default async function activate(context: ExtensionContext) {\n  context.subscriptions.push(\n    languages.registerCompletionItemProvider('markdown', completions, '/'),\n    languages.registerCompletionItemProvider(\n      'markdown',\n      datesCompletionProvider,\n      '/'\n    )\n  );\n}\n\ninterface DateSnippet {\n  snippet: string;\n  date: Date;\n  detail: string;\n}\n\nconst daysOfWeek = [\n  { day: 'sunday', index: 0 },\n  { day: 'monday', index: 1 },\n  { day: 'tuesday', index: 2 },\n  { day: 'wednesday', index: 3 },\n  { day: 'thursday', index: 4 },\n  { day: 'friday', index: 5 },\n  { day: 'saturday', index: 6 },\n];\n\nconst generateDayOfWeekSnippets = (): DateSnippet[] => {\n  const getFutureTarget = (day: number) => {\n    const target = new Date();\n    const currentDay = target.getDay();\n    const distance = (day + 7 - currentDay) % 7;\n    target.setDate(target.getDate() + distance);\n    return target;\n  };\n  // needs work\n  const getPastTarget = (day: number) => {\n    const target = new Date();\n    const currentDay = target.getDay();\n    const distance = currentDay === day ? 7 : (7 + currentDay - day) % 7;\n    target.setDate(target.getDate() - distance);\n    return target;\n  };\n\n  const snippets = daysOfWeek.map(({ day, index }) => {\n    const target = getFutureTarget(index);\n    return {\n      date: target,\n      detail: `Get a daily note link for ${day}`,\n      snippet: `/${day}`,\n    };\n  });\n\n  // append snippets previous days\n  snippets.push(\n    ...daysOfWeek.map(({ day, index }) => {\n      const target = getPastTarget(index);\n      return {\n        date: target,\n        detail: `Get a daily note link for last ${day}`,\n        snippet: `/-${day}`,\n      };\n    })\n  );\n  return snippets;\n};\n\nconst createCompletionItem = ({ snippet, date, detail }: DateSnippet) => {\n  const completionItem = new CompletionItem(\n    snippet,\n    CompletionItemKind.Snippet\n  );\n  completionItem.insertText = getDailyNoteLink(date);\n  completionItem.detail = `${completionItem.insertText} - ${detail}`;\n  if (getFoamVsCodeConfig('dateSnippets.afterCompletion') !== 'noop') {\n    completionItem.command = {\n      command: 'foam-vscode.open-dated-note',\n      title: 'Open a note for the given date',\n      arguments: [date],\n    };\n  }\n  return completionItem;\n};\n\nconst getDailyNoteLink = (date: Date) => {\n  const foamExtension = getFoamVsCodeConfig('openDailyNote.fileExtension');\n  const name = getDailyNoteFileName(date);\n  return `[[${name.replace(`.${foamExtension}`, '')}]]`;\n};\n\nconst snippetFactories: (() => DateSnippet)[] = [\n  () => ({\n    detail: \"Insert a link to today's daily note\",\n    snippet: '/day',\n    date: new Date(),\n  }),\n  () => ({\n    detail: \"Insert a link to today's daily note\",\n    snippet: '/today',\n    date: new Date(),\n  }),\n  () => {\n    const today = new Date();\n    return {\n      detail: \"Insert a link to tomorrow's daily note\",\n      snippet: '/tomorrow',\n      date: new Date(\n        today.getFullYear(),\n        today.getMonth(),\n        today.getDate() + 1\n      ),\n    };\n  },\n  () => {\n    const today = new Date();\n    return {\n      detail: \"Insert a link to yesterday's daily note\",\n      snippet: '/yesterday',\n      date: new Date(\n        today.getFullYear(),\n        today.getMonth(),\n        today.getDate() - 1\n      ),\n    };\n  },\n];\n\nconst computedSnippets: ((number: number) => DateSnippet)[] = [\n  (days: number) => {\n    const today = new Date();\n    return {\n      detail: `Insert a date ${days} day(s) from now`,\n      snippet: `/+${days}d`,\n      date: new Date(\n        today.getFullYear(),\n        today.getMonth(),\n        today.getDate() + days\n      ),\n    };\n  },\n  (weeks: number) => {\n    const today = new Date();\n    return {\n      detail: `Insert a date ${weeks} week(s) from now`,\n      snippet: `/+${weeks}w`,\n      date: new Date(\n        today.getFullYear(),\n        today.getMonth(),\n        today.getDate() + 7 * weeks\n      ),\n    };\n  },\n  (months: number) => {\n    const today = new Date();\n    return {\n      detail: `Insert a date ${months} month(s) from now`,\n      snippet: `/+${months}m`,\n      date: new Date(\n        today.getFullYear(),\n        today.getMonth() + months,\n        today.getDate()\n      ),\n    };\n  },\n  (years: number) => {\n    const today = new Date();\n    return {\n      detail: `Insert a date ${years} year(s) from now`,\n      snippet: `/+${years}y`,\n      date: new Date(\n        today.getFullYear() + years,\n        today.getMonth(),\n        today.getDate()\n      ),\n    };\n  },\n];\n\nconst completions: CompletionItemProvider = {\n  provideCompletionItems: (document, position, _token, _context) => {\n    if (_context.triggerKind === CompletionTriggerKind.Invoke) {\n      // if completion was triggered without trigger character then we return [] to fallback\n      // to vscode word-based suggestions (see https://github.com/foambubble/foam/pull/417)\n      return [];\n    }\n    const range = document.getWordRangeAtPosition(position, /\\S+/);\n    const completionItems = [\n      ...snippetFactories.map(snippetFactory => snippetFactory()),\n      ...generateDayOfWeekSnippets(),\n    ].map(snippet => {\n      const completionItem = createCompletionItem(snippet);\n      completionItem.range = range;\n      return completionItem;\n    });\n    return completionItems;\n  },\n};\n\nexport const datesCompletionProvider: CompletionItemProvider = {\n  provideCompletionItems: (document, position, _token, context) => {\n    if (context.triggerKind === CompletionTriggerKind.Invoke) {\n      // if completion was triggered without trigger character then we return [] to fallback\n      // to vscode word-based suggestions (see https://github.com/foambubble/foam/pull/417)\n      return [];\n    }\n\n    const range = document.getWordRangeAtPosition(position, /\\S+/);\n    const snippetString = document.getText(range);\n    const matches = snippetString.match(/(\\d+)/);\n    const number: string = matches ? matches[0] : '1';\n    const completionItems = computedSnippets.map(item => {\n      const completionItem = createCompletionItem(item(parseInt(number)));\n      completionItem.range = range;\n      return completionItem;\n    });\n    // We still want the list to be treated as \"incomplete\", because the user may add another number\n    return new CompletionList(completionItems, true);\n  },\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/features/document-decorator.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { createTestWorkspace } from '../test/test-utils';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { NavigationProvider } from './navigation-provider';\nimport { FoamGraph } from '../core/model/graph';\nimport { FoamTags } from '../core/model/tags';\nimport { toVsCodeUri } from '../utils/vsc-utils';\n\ndescribe('Document decorator', () => {\n  const parser = createMarkdownParser([]);\n\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  describe('links in .foam directory', () => {\n    it('ctrl-click on a direct path link to an existing .foam file should open the file, not create a new note', async () => {\n      const template = await createFile('Template content', [\n        '.foam',\n        'templates',\n        'template.md',\n      ]);\n      const noteA = await createFile(\n        `link to [template](.foam/templates/template.md).`\n      );\n      // Note: template is NOT in the workspace because .foam/** is excluded from indexing\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      // Position within the link: \"link to [template](.foam/templates/template.md).\"\n      //                                      ^col 9\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions).toBeDefined();\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(template.uri));\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/document-decorator.ts",
    "content": "import { debounce } from 'lodash';\nimport * as vscode from 'vscode';\nimport { ResourceParser } from '../core/model/note';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { Foam } from '../core/model/foam';\nimport { Range } from '../core/model/range';\nimport { fromVsCodeUri } from '../utils/vsc-utils';\n\nconst placeholderDecoration = vscode.window.createTextEditorDecorationType({\n  rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,\n  textDecoration: 'none',\n  color: { id: 'foam.placeholder' },\n  cursor: 'pointer',\n});\n\nconst blockAnchorDecoration = vscode.window.createTextEditorDecorationType({\n  rangeBehavior: vscode.DecorationRangeBehavior.ClosedClosed,\n  opacity: '0.5',\n});\n\nconst updateDecorations =\n  (parser: ResourceParser, workspace: FoamWorkspace) =>\n  (editor: vscode.TextEditor) => {\n    if (!editor || editor.document.languageId !== 'markdown') {\n      return;\n    }\n    const note = parser.parse(\n      fromVsCodeUri(editor.document.uri),\n      editor.document.getText()\n    );\n    const placeholderRanges = [];\n    note.links.forEach(link => {\n      const linkUri = workspace.resolveLink(note, link);\n      if (linkUri.isPlaceholder()) {\n        placeholderRanges.push(\n          Range.create(\n            link.range.start.line,\n            link.range.start.character + (link.type === 'wikilink' ? 2 : 0),\n            link.range.end.line,\n            link.range.end.character - (link.type === 'wikilink' ? 2 : 0)\n          )\n        );\n      }\n    });\n    editor.setDecorations(placeholderDecoration, placeholderRanges);\n\n    editor.setDecorations(\n      blockAnchorDecoration,\n      note.blocks.map(\n        b =>\n          new vscode.Range(\n            b.markerRange.start.line,\n            b.markerRange.start.character,\n            b.markerRange.end.line,\n            b.markerRange.end.character\n          )\n      )\n    );\n  };\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  let activeEditor = vscode.window.activeTextEditor;\n\n  const immediatelyUpdateDecorations = updateDecorations(\n    foam.services.parser,\n    foam.workspace\n  );\n\n  const debouncedUpdateDecorations = debounce(\n    immediatelyUpdateDecorations,\n    500\n  );\n\n  immediatelyUpdateDecorations(activeEditor);\n\n  context.subscriptions.push(\n    placeholderDecoration,\n    blockAnchorDecoration,\n    vscode.window.onDidChangeActiveTextEditor(editor => {\n      activeEditor = editor;\n      immediatelyUpdateDecorations(activeEditor);\n    }),\n    vscode.workspace.onDidChangeTextDocument(event => {\n      if (activeEditor && event.document === activeEditor.document) {\n        debouncedUpdateDecorations(activeEditor);\n      }\n    })\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/heading-rename-provider.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { FoamGraph } from '../core/model/graph';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { createTestWorkspace } from '../test/test-utils';\nimport { toVsCodeUri } from '../utils/vsc-utils';\nimport { HeadingRenameProvider } from './heading-rename-provider';\n\nconst parser = createMarkdownParser([]);\n\nconst buildFoamLike = (ws: ReturnType<typeof createTestWorkspace>) => ({\n  workspace: ws,\n  graph: FoamGraph.fromWorkspace(ws),\n  dispose: () => {},\n});\n\ndescribe('HeadingRenameProvider', () => {\n  beforeEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n\n  describe('prepareRename', () => {\n    it('should return the heading label range and placeholder when cursor is on a heading', async () => {\n      const fileA = await createFile('# My Heading\\n\\nSome content here.\\n');\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n\n      const result = (await provider.prepareRename(\n        doc,\n        new vscode.Position(0, 5),\n        new vscode.CancellationTokenSource().token\n      )) as { range: vscode.Range; placeholder: string };\n\n      expect(result.placeholder).toBe('My Heading');\n      expect(result.range.start.character).toBe(2); // after \"# \"\n      expect(result.range.end.character).toBe(12); // \"My Heading\".length = 10, start=2, end=12\n    });\n\n    it('should throw when cursor is not on a heading line', async () => {\n      const fileA = await createFile('# My Heading\\n\\nSome body text.\\n');\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n\n      await expect(\n        provider.prepareRename(\n          doc,\n          new vscode.Position(2, 0),\n          new vscode.CancellationTokenSource().token\n        )\n      ).rejects.toThrow('Cannot rename: cursor is not on a heading');\n    });\n  });\n\n  describe('provideRenameEdits', () => {\n    it('should update the heading text and all wikilinks referencing it', async () => {\n      const fileA = await createFile('# Old Heading\\n\\nContent.\\n');\n      const fileB = await createFile(\n        `# Note B\\n\\nSee [[${fileA.name}#Old Heading]] for more.\\n`\n      );\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 5),\n        'New Heading',\n        new vscode.CancellationTokenSource().token\n      );\n\n      expect(edits).toBeDefined();\n\n      // The heading text edit on fileA\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      expect(fileAEdits).toHaveLength(1);\n      expect(fileAEdits[0].newText).toBe('New Heading');\n\n      // The wikilink update edit on fileB\n      const fileBEdits = edits.get(toVsCodeUri(fileB.uri));\n      expect(fileBEdits).toHaveLength(1);\n      expect(fileBEdits[0].newText).toContain('New Heading');\n      expect(fileBEdits[0].newText).not.toContain('Old Heading');\n    });\n\n    it('should update self-referencing section links within the same file', async () => {\n      const fileA = await createFile(\n        '# Old Heading\\n\\nJump to [[#Old Heading]].\\n'\n      );\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 5),\n        'New Heading',\n        new vscode.CancellationTokenSource().token\n      );\n\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      // One edit for the heading text, one for the self-referencing link\n      expect(fileAEdits).toHaveLength(2);\n      const newTexts = fileAEdits.map(e => e.newText);\n      expect(newTexts).toContain('New Heading');\n      expect(newTexts.some(t => t.includes('[[#New Heading]]'))).toBe(true);\n    });\n\n    it('should not update links that reference a different section', async () => {\n      const fileA = await createFile(\n        '# Old Heading\\n## Another Heading\\n\\nContent.\\n'\n      );\n      const fileB = await createFile(\n        `See [[${fileA.name}#Another Heading]] only.\\n`\n      );\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 5),\n        'Renamed Heading',\n        new vscode.CancellationTokenSource().token\n      );\n\n      // Only the heading text in fileA should be updated\n      const fileAEdits = edits.get(toVsCodeUri(fileA.uri));\n      expect(fileAEdits).toHaveLength(1);\n      expect(fileAEdits[0].newText).toBe('Renamed Heading');\n\n      // fileB's link points to \"Another Heading\", should not be changed\n      const fileBEdits = edits.get(toVsCodeUri(fileB.uri));\n      expect(fileBEdits ?? []).toHaveLength(0);\n    });\n\n    it('should update links using reference-style definitions', async () => {\n      const refLinkContent = [\n        '# Note B',\n        '',\n        'See [the reference][ref1] for more.',\n        '',\n        '[ref1]: <note-a#Old Heading>',\n      ].join('\\n');\n\n      const fileA = await createFile('# Old Heading\\n\\nContent.\\n', [\n        'note-a.md',\n      ]);\n      const fileB = await createFile(refLinkContent);\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const foam = buildFoamLike(ws);\n      const provider = new HeadingRenameProvider(foam as any);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const edits = await provider.provideRenameEdits(\n        doc,\n        new vscode.Position(0, 5),\n        'New Heading',\n        new vscode.CancellationTokenSource().token\n      );\n\n      const fileBEdits = edits.get(toVsCodeUri(fileB.uri));\n      expect(fileBEdits).toBeDefined();\n      expect(fileBEdits).toHaveLength(1);\n      // The edit should update the definition line, not the inline text\n      expect(fileBEdits[0].newText).toContain('[ref1]: <note-a#New Heading>');\n      expect(fileBEdits[0].newText).not.toContain('the reference');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/heading-rename-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { Resource, Section } from '../core/model/note';\nimport { HeadingEdit } from '../core/services/heading-edit';\nimport { Logger } from '../core/utils/log';\nimport { Position } from '../core/model/position';\nimport { Range } from '../core/model/range';\nimport {\n  fromVsCodeUri,\n  toVsCodeRange,\n  toVsCodeWorkspaceEdit,\n} from '../utils/vsc-utils';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  const provider = new HeadingRenameProvider(foam);\n\n  context.subscriptions.push(\n    vscode.languages.registerRenameProvider('markdown', provider)\n  );\n}\n\nexport class HeadingRenameProvider implements vscode.RenameProvider {\n  constructor(private foam: Foam) {}\n\n  async prepareRename(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    _token: vscode.CancellationToken\n  ): Promise<vscode.Range | { range: vscode.Range; placeholder: string }> {\n    const section = this.getSectionOnHeadingLine(document, position);\n    if (!section) {\n      throw new Error('Cannot rename: cursor is not on a heading');\n    }\n\n    return {\n      range: toVsCodeRange(\n        getHeadingLabelRange(document, section.range.start.line, section.label)\n      ),\n      placeholder: section.label,\n    };\n  }\n\n  provideRenameEdits(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    newName: string,\n    _token: vscode.CancellationToken\n  ): vscode.ProviderResult<vscode.WorkspaceEdit> {\n    const section = this.getSectionOnHeadingLine(document, position);\n    if (!section) {\n      throw new Error('Cannot rename: cursor is not on a heading');\n    }\n\n    const fileUri = fromVsCodeUri(document.uri);\n    const oldLabel = section.label;\n\n    // Edit 1: update the heading text in the current document\n    const headingLabelRange = getHeadingLabelRange(\n      document,\n      section.range.start.line,\n      oldLabel\n    );\n    const headingEdit = {\n      uri: fileUri,\n      edit: {\n        range: Range.create(\n          headingLabelRange.start.line,\n          headingLabelRange.start.character,\n          headingLabelRange.end.line,\n          headingLabelRange.end.character\n        ),\n        newText: newName,\n      },\n    };\n\n    // Edit 2+: update all links pointing to this section\n    const linkEditResult = HeadingEdit.createRenameSectionEdits(\n      this.foam.graph,\n      this.foam.workspace,\n      fileUri,\n      oldLabel,\n      newName\n    );\n\n    const allEdits = [headingEdit, ...linkEditResult.edits];\n\n    Logger.info(\n      `Renaming heading \"${oldLabel}\" to \"${newName}\" (${linkEditResult.totalOccurrences} link(s) updated)`\n    );\n\n    return toVsCodeWorkspaceEdit(allEdits, this.foam.workspace);\n  }\n\n  /**\n   * Returns the section at the given position only when the position is on the\n   * heading line itself (not in the section body). Uses Resource.getSectionAtPosition\n   * to find the enclosing section, then verifies the line matches the heading.\n   */\n  private getSectionOnHeadingLine(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): Section | undefined {\n    const resource = this.foam.workspace.find(fromVsCodeUri(document.uri));\n    if (!resource) {\n      return undefined;\n    }\n    const foamPosition = Position.create(position.line, position.character);\n    const section = Resource.getSectionAtPosition(resource, foamPosition);\n    if (!section || section.range.start.line !== position.line) {\n      return undefined;\n    }\n    return section;\n  }\n}\n\n/**\n * Computes the VS Code range covering just the heading label text (excluding\n * the `#` marks and space prefix) on the given line.\n */\nfunction getHeadingLabelRange(\n  document: vscode.TextDocument,\n  line: number,\n  label: string\n): vscode.Range {\n  const lineText = document.lineAt(line).text;\n  const prefixMatch = /^#{1,6}\\s+/.exec(lineText);\n  const prefixLen = prefixMatch ? prefixMatch[0].length : 0;\n  return new vscode.Range(line, prefixLen, line, prefixLen + label.length);\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/hover-provider.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { MarkdownResourceProvider } from '../core/services/markdown-provider';\nimport { FoamGraph } from '../core/model/graph';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { toVsCodeUri } from '../utils/vsc-utils';\nimport { HoverProvider } from './hover-provider';\nimport { readFileFromFs } from '../test/test-utils';\nimport { FileDataStore } from '../test/test-datastore';\n\n// We can't use createTestWorkspace from /packages/foam-vscode/src/test/test-utils.ts\n// because we need a MarkdownResourceProvider with a real instance of FileDataStore.\nconst createWorkspace = () => {\n  const dataStore = new FileDataStore(\n    readFileFromFs,\n    vscode.workspace.workspaceFolders[0].uri.fsPath\n  );\n  const parser = createMarkdownParser();\n  const resourceProvider = new MarkdownResourceProvider(dataStore, parser);\n  const workspace = new FoamWorkspace();\n  workspace.registerProvider(resourceProvider);\n  return workspace;\n};\n\nconst getValue = (value: vscode.MarkdownString | vscode.MarkedString) =>\n  value instanceof vscode.MarkdownString ? value.value : value;\n\ndescribe('Hover provider', () => {\n  const noCancelToken: vscode.CancellationToken = {\n    isCancellationRequested: false,\n    onCancellationRequested: null,\n  };\n  const parser = createMarkdownParser([]);\n  const hoverEnabled = () => true;\n\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  describe('not returning hovers', () => {\n    it('should not return hover content for empty documents', async () => {\n      const { uri, content } = await createFile('');\n      const ws = createWorkspace().set(parser.parse(uri, content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n\n      const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));\n      const pos = new vscode.Position(0, 0);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result).toBeUndefined();\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should not return hover content for documents without links', async () => {\n      const { uri, content } = await createFile(\n        'This is some content without links'\n      );\n      const ws = createWorkspace().set(parser.parse(uri, content));\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n\n      const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));\n      const pos = new vscode.Position(0, 0);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result).toBeUndefined();\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should not return hover content when the cursor is not placed on a wikilink', async () => {\n      const fileB = await createFile('# File B\\nThe content of file B');\n      const fileA = await createFile(\n        `this is a link to [[${fileB.name}]] end of the line.`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const noteB = parser.parse(fileB.uri, fileB.content);\n      const ws = createWorkspace().set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 11); // Set cursor position beside the wikilink.\n\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n      expect(result).toBeUndefined();\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should not return hover content for a placeholder', async () => {\n      const fileA = await createFile(\n        `this is a link to [[a placeholder]] end of the line.`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const ws = createWorkspace().set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the placeholder.\n\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n      expect(result.contents[0]).toBeNull();\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should not return hover when provider is disabled', async () => {\n      const fileB = await createFile(`this is my file content`);\n      const fileA = await createFile(\n        `this is a link to [[${fileB.name}]] end of the line.`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const noteB = parser.parse(fileB.uri, fileB.content);\n\n      const ws = createWorkspace().set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the wikilink.\n\n      const disabledProvider = new HoverProvider(\n        () => false,\n        ws,\n        graph,\n        parser\n      );\n      expect(\n        await disabledProvider.provideHover(doc, pos, noCancelToken)\n      ).toBeUndefined();\n      ws.dispose();\n      graph.dispose();\n    });\n  });\n\n  describe('wikilink content preview', () => {\n    it('should return hover content for a wikilink', async () => {\n      const fileB = await createFile(`This is some content from file B`);\n      const fileA = await createFile(\n        `this is a link to [[${fileB.name}]] end of the line.`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const noteB = parser.parse(fileB.uri, fileB.content);\n\n      const ws = createWorkspace().set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the wikilink.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(getValue(result.contents[0])).toEqual(\n        `This is some content from file B`\n      );\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should return hover content for a regular link', async () => {\n      const fileB = await createFile(`This is some content from file B`);\n      const fileA = await createFile(\n        `this is a link to [a file](./${fileB.base}).`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const noteB = parser.parse(fileB.uri, fileB.content);\n      const ws = createWorkspace().set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(getValue(result.contents[0])).toEqual(\n        `This is some content from file B`\n      );\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should remove YAML properties from preview', async () => {\n      const fileB = await createFile(`---\ntags: my-tag1 my-tag2\n---      \n    \nThe content of file B`);\n      const fileA = await createFile(\n        `this is a link to [a file](./${fileB.base}).`\n      );\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const noteB = parser.parse(fileB.uri, fileB.content);\n      const ws = createWorkspace().set(noteA).set(noteB);\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(getValue(result.contents[0])).toEqual(`The content of file B`);\n      ws.dispose();\n      graph.dispose();\n    });\n  });\n\n  describe('backlink inclusion in hover', () => {\n    it('should not include references if there are none', async () => {\n      const fileA = await createFile(`This is some [[wikilink]]`);\n\n      const ws = createWorkspace().set(parser.parse(fileA.uri, fileA.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const pos = new vscode.Position(0, 20); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(result.contents[0]).toEqual(null);\n      expect(result.contents[1]).toEqual(null);\n      expect(getValue(result.contents[2])).toMatch(\n        \"[Create note from template for 'wikilink'](command:foam-vscode.create-note?\"\n      );\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should include other backlinks (but not self) to target wikilink', async () => {\n      const fileA = await createFile(`This is some content`);\n      const fileB = await createFile(\n        `This is a direct link to [a file](./${fileA.base}).`\n      );\n      const fileC = await createFile(`Here is a wikilink to [[${fileA.name}]]`);\n\n      const ws = createWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content))\n        .set(parser.parse(fileC.uri, fileC.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const pos = new vscode.Position(0, 29); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(getValue(result.contents[0])).toEqual(`This is some content`);\n      expect(getValue(result.contents[1])).toMatch(\n        /^Also referenced in 1 note:/\n      );\n      expect(result.contents[2]).toEqual(null);\n      ws.dispose();\n      graph.dispose();\n    });\n\n    it('should only add a note only once no matter how many links it has to the target', async () => {\n      const fileA = await createFile(`This is some content`);\n      const fileB = await createFile(`This is a link to [[${fileA.name}]].`);\n      const fileC = await createFile(\n        `This note is linked to [[${fileA.name}]] twice, here is the second: [[${fileA.name}]]`\n      );\n\n      const ws = createWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content))\n        .set(parser.parse(fileC.uri, fileC.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const pos = new vscode.Position(0, 22); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(getValue(result.contents[1])).toMatch(\n        /^Also referenced in 1 note:/\n      );\n      ws.dispose();\n      graph.dispose();\n    });\n    it('should work for placeholders', async () => {\n      const fileA = await createFile(`Some content and a [[placeholder]]`);\n      const fileB = await createFile(`More content to a [[placeholder]]`);\n      const fileC = await createFile(`Yet more content to a [[placeholder]]`);\n\n      const ws = createWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content))\n        .set(parser.parse(fileC.uri, fileC.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const pos = new vscode.Position(0, 24); // Set cursor position on the link.\n\n      const provider = new HoverProvider(hoverEnabled, ws, graph, parser);\n      const result = await provider.provideHover(doc, pos, noCancelToken);\n\n      expect(result.contents).toHaveLength(3);\n      expect(result.contents[0]).toEqual(null);\n      expect(getValue(result.contents[1])).toMatch(\n        /^Also referenced in 2 notes:/\n      );\n      expect(getValue(result.contents[2])).toMatch(\n        \"[Create note from template for 'placeholder'](command:foam-vscode.create-note?\"\n      );\n      ws.dispose();\n      graph.dispose();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/hover-provider.ts",
    "content": "import { uniqWith } from 'lodash';\nimport * as vscode from 'vscode';\nimport { fromVsCodeUri, toVsCodeRange } from '../utils/vsc-utils';\nimport {\n  ConfigurationMonitor,\n  monitorFoamVsCodeConfig,\n} from '../services/config';\nimport { ResourceLink, ResourceParser } from '../core/model/note';\nimport { Foam } from '../core/model/foam';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { Range } from '../core/model/range';\nimport { FoamGraph } from '../core/model/graph';\nimport { OPEN_COMMAND } from './commands/open-resource';\nimport { CREATE_NOTE_COMMAND } from './commands/create-note';\nimport { commandAsURI } from '../utils/commands';\nimport { Location } from '../core/model/location';\nimport { getNoteTooltip, getFoamDocSelectors } from '../services/editor';\nimport { isSome } from '../core/utils';\n\nexport const CONFIG_KEY = 'links.hover.enable';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const isHoverEnabled: ConfigurationMonitor<boolean> =\n    monitorFoamVsCodeConfig(CONFIG_KEY);\n\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    isHoverEnabled,\n    vscode.languages.registerHoverProvider(\n      getFoamDocSelectors(),\n      new HoverProvider(\n        isHoverEnabled,\n        foam.workspace,\n        foam.graph,\n        foam.services.parser\n      )\n    )\n  );\n}\n\nexport class HoverProvider implements vscode.HoverProvider {\n  constructor(\n    private isHoverEnabled: () => boolean,\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    private parser: ResourceParser\n  ) {}\n\n  async provideHover(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    token: vscode.CancellationToken\n  ): Promise<vscode.Hover> {\n    if (!this.isHoverEnabled()) {\n      return;\n    }\n\n    const startResource = this.parser.parse(\n      fromVsCodeUri(document.uri),\n      document.getText()\n    );\n\n    const targetLink: ResourceLink | undefined = startResource.links.find(\n      link =>\n        Range.containsPosition(link.range, {\n          line: position.line,\n          character: position.character,\n        })\n    );\n    if (!targetLink) {\n      return;\n    }\n\n    const documentUri = fromVsCodeUri(document.uri);\n    const targetUri = this.workspace.resolveLink(startResource, targetLink);\n    const sources = uniqWith(\n      this.graph\n        .getBacklinks(targetUri)\n        .filter(link => !link.source.isEqual(documentUri))\n        .map(link => link.source),\n      (u1, u2) => u1.isEqual(u2)\n    );\n\n    const links = sources.slice(0, 10).map(ref => {\n      const command = commandAsURI(OPEN_COMMAND.forURI(ref));\n      return `- [${this.workspace.get(ref).title}](${command.toString()})`;\n    });\n\n    const notes = `note${sources.length > 1 ? 's' : ''}`;\n    const references = getNoteTooltip(\n      [\n        `Also referenced in ${sources.length} ${notes}:`,\n        ...links,\n        links.length === sources.length ? '' : '- ...',\n      ].join('\\n')\n    );\n\n    let mdContent = null;\n    if (!targetUri.isPlaceholder()) {\n      const content: string = await this.workspace.readAsMarkdown(targetUri);\n\n      mdContent = isSome(content)\n        ? getNoteTooltip(content)\n        : this.workspace.get(targetUri).title;\n    }\n\n    const command = CREATE_NOTE_COMMAND.forPlaceholder(\n      Location.forObjectWithRange(documentUri, targetLink),\n      this.workspace.defaultExtension,\n      {\n        askForTemplate: true,\n        onFileExists: 'open',\n      }\n    );\n    const newNoteFromTemplate = new vscode.MarkdownString(\n      `[Create note from template for '${targetUri.getBasename()}'](${commandAsURI(\n        command\n      ).toString()})`\n    );\n    newNoteFromTemplate.isTrusted = true;\n\n    const hover: vscode.Hover = {\n      contents: [\n        mdContent,\n        sources.length > 0 ? references : null,\n        targetUri.isPlaceholder() ? newNoteFromTemplate : null,\n      ],\n      range: toVsCodeRange(targetLink.range),\n    };\n    return hover;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/index.ts",
    "content": "import { FoamFeature } from '../types';\nimport * as commands from './commands';\nimport * as panels from './panels';\nimport dateSnippets from './date-snippets';\nimport hoverProvider from './hover-provider';\nimport preview from './preview';\nimport completionProvider from './link-completion';\nimport tagCompletionProvider from './tag-completion';\nimport linkDecorations from './document-decorator';\nimport navigationProviders from './navigation-provider';\nimport wikilinkDiagnostics from './wikilink-diagnostics';\nimport refactor from './refactor';\nimport workspaceSymbolProvider from './workspace-symbol-provider';\nimport tagRenameProvider from './tag-rename-provider';\nimport headingRenameProvider from './heading-rename-provider';\nimport blockRenameProvider from './block-rename-provider';\n\nexport const features: FoamFeature[] = [\n  ...Object.values(commands),\n  ...Object.values(panels),\n  refactor,\n  navigationProviders,\n  wikilinkDiagnostics,\n  dateSnippets,\n  hoverProvider,\n  linkDecorations,\n  preview,\n  completionProvider,\n  tagCompletionProvider,\n  workspaceSymbolProvider,\n  tagRenameProvider,\n  headingRenameProvider,\n  blockRenameProvider,\n];\n"
  },
  {
    "path": "packages/foam-vscode/src/features/link-completion.spec.ts",
    "content": "/* @unit-ready */\n\nimport * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { FoamGraph } from '../core/model/graph';\nimport { createTestNote, createTestWorkspace } from '../test/test-utils';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n  withModifiedFoamConfiguration,\n} from '../test/test-utils-vscode';\nimport { fromVsCodeUri } from '../utils/vsc-utils';\nimport {\n  WikilinkCompletionProvider,\n  SectionCompletionProvider,\n} from './link-completion';\nimport { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';\n\ndescribe('Link Completion', () => {\n  const parser = createMarkdownParser([]);\n  const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);\n  const ws = createTestWorkspace();\n  ws.set(\n    createTestNote({\n      root,\n      uri: 'file-name.md',\n      sections: ['Section One', 'Section Two'],\n    })\n  )\n    .set(\n      createTestNote({\n        root,\n        uri: 'File name with spaces.md',\n      })\n    )\n    .set(\n      createTestNote({\n        root,\n        uri: 'path/to/file.md',\n        links: [{ slug: 'placeholder text' }],\n      })\n    )\n    .set(\n      createTestNote({\n        root,\n        uri: 'another/file.md',\n      })\n    );\n  const graph = FoamGraph.fromWorkspace(ws);\n\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    ws.dispose();\n    graph.dispose();\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  it('should not return any link for empty documents', async () => {\n    const { uri } = await createFile('');\n    const { doc } = await showInEditor(uri);\n    const provider = new WikilinkCompletionProvider(ws, graph);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 0)\n    );\n\n    expect(links).toBeNull();\n  });\n\n  it('should not return link outside the wikilink brackets', async () => {\n    const { uri } = await createFile('[[file]] then');\n    const { doc } = await showInEditor(uri);\n    const provider = new WikilinkCompletionProvider(ws, graph);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 12)\n    );\n\n    expect(links).toBeNull();\n  });\n\n  it('should return notes with unique identifiers, and placeholders', async () => {\n    for (const text of ['[[', '[[file]] [[', '[[file]] #tag [[']) {\n      const { uri } = await createFile(text);\n      const { doc } = await showInEditor(uri);\n      const provider = new WikilinkCompletionProvider(ws, graph);\n\n      const links = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(0, text.length)\n      );\n\n      expect(links.items.length).toEqual(5);\n      expect(new Set(links.items.map(i => i.insertText))).toEqual(\n        new Set([\n          'to/file',\n          'another/file',\n          'File name with spaces',\n          'file-name',\n          'placeholder text',\n        ])\n      );\n    }\n  });\n\n  it('should support label setting', async () => {\n    const { uri: noteUri, content } = await createFile(`# My Note Title`);\n    const workspace = createTestWorkspace();\n    workspace.set(parser.parse(noteUri, content));\n    const provider = new WikilinkCompletionProvider(\n      workspace,\n      FoamGraph.fromWorkspace(workspace)\n    );\n\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n\n    await withModifiedFoamConfiguration(\n      'completion.label',\n      'title',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 3)\n        );\n\n        expect(links.items.map(i => i.label)).toEqual(['My Note Title']);\n      }\n    );\n\n    await withModifiedFoamConfiguration(\n      'completion.label',\n      'path',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 3)\n        );\n\n        expect(links.items.map(i => i.label)).toEqual([noteUri.getBasename()]);\n      }\n    );\n\n    await withModifiedFoamConfiguration(\n      'completion.label',\n      'identifier',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 3)\n        );\n\n        expect(links.items.map(i => i.label)).toEqual([\n          workspace.getIdentifier(noteUri),\n        ]);\n      }\n    );\n  });\n\n  it('should support alias setting', async () => {\n    const { uri: noteUri, content } = await createFile(`# My Note Title`);\n    const workspace = createTestWorkspace();\n    workspace.set(parser.parse(noteUri, content));\n    const provider = new WikilinkCompletionProvider(\n      workspace,\n      FoamGraph.fromWorkspace(workspace)\n    );\n\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n\n    await withModifiedFoamConfiguration(\n      'completion.useAlias',\n      'never',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 3)\n        );\n\n        expect(links.items.map(i => i.insertText)).toEqual([\n          workspace.getIdentifier(noteUri),\n        ]);\n      }\n    );\n\n    await withModifiedFoamConfiguration(\n      'completion.useAlias',\n      'whenPathDiffersFromTitle',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 3)\n        );\n\n        expect(links.items.map(i => i.insertText)).toEqual([\n          `${workspace.getIdentifier(noteUri)}|My Note Title`,\n        ]);\n      }\n    );\n  });\n\n  it('should return sections for other notes', async () => {\n    for (const text of [\n      '[[file-name#',\n      '[[file]] [[file-name#',\n      '[[file]] #tag [[file-name#',\n    ]) {\n      const { uri } = await createFile(text);\n      const { doc } = await showInEditor(uri);\n      const provider = new SectionCompletionProvider(ws);\n\n      const links = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(0, text.length)\n      );\n\n      expect(new Set(links.items.map(i => i.label))).toEqual(\n        new Set(['Section One', 'Section Two'])\n      );\n    }\n  });\n\n  it('should return sections within the note', async () => {\n    const { uri, content } = await createFile(`\n# Section 1\n\nContent of section 1\n\n# Section 2\n\nContent of section 2\n\n[[#\n`);\n    ws.set(parser.parse(uri, content));\n\n    const { doc } = await showInEditor(uri);\n    const provider = new SectionCompletionProvider(ws);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(9, 3)\n    );\n\n    expect(new Set(links.items.map(i => i.label))).toEqual(\n      new Set(['Section 1', 'Section 2'])\n    );\n  });\n\n  it('should return block IDs when fragment starts with ^', async () => {\n    const { uri, content } = await createFile(`\nA paragraph ^block-one\n\nAnother paragraph ^block-two\n\n[[#^\n`);\n    ws.set(parser.parse(uri, content));\n\n    const { doc } = await showInEditor(uri);\n    const provider = new SectionCompletionProvider(ws);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(5, 5)\n    );\n\n    expect(new Set(links.items.map(i => i.label))).toEqual(\n      new Set(['^block-one', '^block-two'])\n    );\n  });\n\n  it('should return block IDs for another note when fragment starts with ^', async () => {\n    const { uri, content } = await createFile(`A paragraph ^myblock`, [\n      'note-with-blocks.md',\n    ]);\n    ws.set(parser.parse(uri, content));\n\n    const text = '[[note-with-blocks#^';\n    const { uri: fileUri } = await createFile(text);\n    const { doc } = await showInEditor(fileUri);\n    const provider = new SectionCompletionProvider(ws);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, text.length)\n    );\n\n    expect(links.items.map(i => i.label)).toContain('^myblock');\n    expect(links.items.find(i => i.label === '^myblock').detail).toBe(\n      'paragraph'\n    );\n  });\n\n  it('should return empty list when note has no block anchors', async () => {\n    const text = '[[file-name#^';\n    const { uri } = await createFile(text);\n    const { doc } = await showInEditor(uri);\n    const provider = new SectionCompletionProvider(ws);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, text.length)\n    );\n\n    // file-name.md has sections but no blocks\n    expect(links.items).toHaveLength(0);\n  });\n\n  it('should return page alias', async () => {\n    const { uri, content } = await createFile(\n      `\n---\nalias: alias-a\n---\n[[\n`,\n      ['new-note-with-alias.md']\n    );\n    ws.set(parser.parse(uri, content));\n\n    const { doc } = await showInEditor(uri);\n    const provider = new WikilinkCompletionProvider(ws, graph);\n\n    const links = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(4, 2)\n    );\n\n    const aliasCompletionItem = links.items.find(i => i.label === 'alias-a');\n    expect(aliasCompletionItem).not.toBeNull();\n    expect(aliasCompletionItem.label).toBe('alias-a');\n    expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a');\n  });\n\n  it('should support linkFormat setting - wikilink format (default)', async () => {\n    const { uri: noteUri, content } = await createFile(`# My Note Title`);\n    const workspace = createTestWorkspace();\n    workspace.set(parser.parse(noteUri, content));\n    const provider = new WikilinkCompletionProvider(\n      workspace,\n      FoamGraph.fromWorkspace(workspace)\n    );\n\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'wikilink',\n      async () => {\n        await withModifiedFoamConfiguration(\n          'completion.useAlias',\n          'never',\n          async () => {\n            const links = await provider.provideCompletionItems(\n              doc,\n              new vscode.Position(0, 2)\n            );\n\n            expect(links.items.length).toBe(1);\n            expect(links.items[0].insertText).toBe(\n              workspace.getIdentifier(noteUri)\n            );\n          }\n        );\n      }\n    );\n  });\n\n  it('should support linkFormat setting - markdown link format', async () => {\n    const { uri: noteUri, content } = await createFile(`# My Note Title`, [\n      'my',\n      'path',\n      'to',\n      'test-note.md',\n    ]);\n    const workspace = createTestWorkspace();\n    workspace.set(parser.parse(noteUri, content));\n    const provider = new WikilinkCompletionProvider(\n      workspace,\n      FoamGraph.fromWorkspace(workspace)\n    );\n\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'link',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 2)\n        );\n\n        expect(links.items.length).toBe(1);\n        const insertText = String(links.items[0].insertText);\n\n        // In test environment, the command converts wikilink to markdown after insertion\n        // The insertText is the wikilink format, conversion happens via command\n        // So we expect just the identifier (no alias because linkFormat === 'link')\n        expect(insertText).toBe(workspace.getIdentifier(noteUri));\n\n        // Commit characters should be empty when using conversion command\n        expect(links.items[0].commitCharacters).toEqual([]);\n\n        // Verify command is attached for conversion\n        expect(links.items[0].command).toBeDefined();\n        expect(links.items[0].command.command).toBe(\n          CONVERT_WIKILINK_TO_MDLINK.command\n        );\n      }\n    );\n  });\n\n  it('should support linkFormat setting with aliases - markdown format', async () => {\n    const { uri: noteUri, content } = await createFile(`# My Different Title`, [\n      'another-note.md',\n    ]);\n    const workspace = createTestWorkspace();\n    workspace.set(parser.parse(noteUri, content));\n    const provider = new WikilinkCompletionProvider(\n      workspace,\n      FoamGraph.fromWorkspace(workspace)\n    );\n\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'link',\n      async () => {\n        await withModifiedFoamConfiguration(\n          'completion.useAlias',\n          'whenPathDiffersFromTitle',\n          async () => {\n            const links = await provider.provideCompletionItems(\n              doc,\n              new vscode.Position(0, 2)\n            );\n\n            expect(links.items.length).toBe(1);\n            const insertText = links.items[0].insertText;\n\n            // When linkFormat is 'link', we don't use alias in insertText\n            // The conversion command handles the title mapping\n            expect(insertText).toBe(workspace.getIdentifier(noteUri));\n            expect(links.items[0].commitCharacters).toEqual([]);\n\n            // Verify command is attached for conversion\n            expect(links.items[0].command).toBeDefined();\n            expect(links.items[0].command.command).toBe(\n              CONVERT_WIKILINK_TO_MDLINK.command\n            );\n          }\n        );\n      }\n    );\n  });\n\n  it('should handle alias completion with markdown link format', async () => {\n    const { uri, content } = await createFile(\n      `\n---\nalias: test-alias\n---\n[[\n`,\n      ['note-with-alias.md']\n    );\n    ws.set(parser.parse(uri, content));\n\n    const { doc } = await showInEditor(uri);\n    const provider = new WikilinkCompletionProvider(ws, graph);\n\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'link',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(4, 2)\n        );\n\n        const aliasCompletionItem = links.items.find(\n          i => i.label === 'test-alias'\n        );\n        expect(aliasCompletionItem).not.toBeNull();\n        expect(aliasCompletionItem.label).toBe('test-alias');\n\n        // Alias completions always use pipe syntax in insertText\n        // The conversion command will convert it to markdown format\n        expect(aliasCompletionItem.insertText).toBe(\n          'note-with-alias|test-alias'\n        );\n        expect(aliasCompletionItem.commitCharacters).toEqual([]);\n\n        // Verify command is attached for conversion\n        expect(aliasCompletionItem.command).toBeDefined();\n        expect(aliasCompletionItem.command.command).toBe(\n          CONVERT_WIKILINK_TO_MDLINK.command\n        );\n      }\n    );\n  });\n\n  describe('Directory index completion', () => {\n    it('should use directory name as identifier when mode is resolve', async () => {\n      const workspace = createTestWorkspace();\n      workspace.set(createTestNote({ uri: '/root/bar/index.md' }));\n      const provider = new WikilinkCompletionProvider(\n        workspace,\n        FoamGraph.fromWorkspace(workspace)\n      );\n\n      const { uri } = await createFile('[[');\n      const { doc } = await showInEditor(uri);\n\n      await withModifiedFoamConfiguration(\n        'links.directory.mode',\n        'resolve',\n        async () => {\n          const links = await provider.provideCompletionItems(\n            doc,\n            new vscode.Position(0, 2)\n          );\n          expect(links.items.map(i => String(i.insertText))).toContain('bar');\n        }\n      );\n    });\n\n    it('should use file identifier when mode is disabled', async () => {\n      const workspace = createTestWorkspace();\n      workspace.set(createTestNote({ uri: '/root/bar/index.md' }));\n      const provider = new WikilinkCompletionProvider(\n        workspace,\n        FoamGraph.fromWorkspace(workspace)\n      );\n\n      const { uri } = await createFile('[[');\n      const { doc } = await showInEditor(uri);\n\n      await withModifiedFoamConfiguration(\n        'links.directory.mode',\n        'disabled',\n        async () => {\n          const links = await provider.provideCompletionItems(\n            doc,\n            new vscode.Position(0, 2)\n          );\n          expect(links.items.map(i => String(i.insertText))).toContain('index');\n        }\n      );\n    });\n\n    it('should fall back to file identifier when directory name is shadowed by a regular file', async () => {\n      const workspace = createTestWorkspace();\n      workspace.set(createTestNote({ uri: '/root/bar.md' }));\n      workspace.set(createTestNote({ uri: '/root/bar/index.md' }));\n      const provider = new WikilinkCompletionProvider(\n        workspace,\n        FoamGraph.fromWorkspace(workspace)\n      );\n\n      const { uri } = await createFile('[[');\n      const { doc } = await showInEditor(uri);\n\n      await withModifiedFoamConfiguration(\n        'links.directory.mode',\n        'resolve',\n        async () => {\n          const links = await provider.provideCompletionItems(\n            doc,\n            new vscode.Position(0, 2)\n          );\n          const insertTexts = links.items.map(i => String(i.insertText));\n          // bar.md gets 'bar', bar/index.md falls back to 'index' (not 'bar' again)\n          expect(insertTexts.filter(t => t === 'bar')).toHaveLength(1);\n          expect(insertTexts).toContain('index');\n        }\n      );\n    });\n  });\n\n  it('should ignore linkFormat setting for placeholder completions', async () => {\n    const { uri } = await createFile('[[');\n    const { doc } = await showInEditor(uri);\n    const provider = new WikilinkCompletionProvider(ws, graph);\n\n    // Test with wikilink format - should return plain text\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'wikilink',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 2)\n        );\n\n        const placeholderItem = links.items.find(\n          i => i.label === 'placeholder text'\n        );\n        expect(placeholderItem).not.toBeNull();\n        expect(placeholderItem.insertText).toBe('placeholder text');\n      }\n    );\n\n    // Test with markdown link format - should also return plain text (ignore format conversion)\n    await withModifiedFoamConfiguration(\n      'completion.linkFormat',\n      'link',\n      async () => {\n        const links = await provider.provideCompletionItems(\n          doc,\n          new vscode.Position(0, 2)\n        );\n\n        const placeholderItem = links.items.find(\n          i => i.label === 'placeholder text'\n        );\n        expect(placeholderItem).not.toBeNull();\n        // Placeholders should remain as plain text, not converted to wikilink format\n        expect(placeholderItem.insertText).toBe('placeholder text');\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/link-completion.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { FoamGraph } from '../core/model/graph';\nimport { Resource } from '../core/model/note';\nimport { URI } from '../core/model/uri';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { getFoamVsCodeConfig } from '../services/config';\nimport { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';\nimport { getNoteTooltip, getFoamDocSelectors } from '../services/editor';\nimport { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';\nimport { getDirectoryModeSetting } from '../settings';\n\nexport const aliasCommitCharacters = ['#'];\nexport const linkCommitCharacters = ['#', '|'];\nexport const sectionCommitCharacters = ['|'];\n\nconst COMPLETION_CURSOR_MOVE = {\n  command: 'foam-vscode.completion-move-cursor',\n  title: 'Foam: Move cursor after completion',\n};\n\nexport const WIKILINK_REGEX = /\\[\\[[^[\\]]*(?!.*\\]\\])/;\nexport const SECTION_REGEX = /\\[\\[([^[\\]]*#(?!.*\\]\\]))/;\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  context.subscriptions.push(\n    vscode.languages.registerCompletionItemProvider(\n      getFoamDocSelectors(),\n      new WikilinkCompletionProvider(foam.workspace, foam.graph),\n      '['\n    ),\n    vscode.languages.registerCompletionItemProvider(\n      getFoamDocSelectors(),\n      new SectionCompletionProvider(foam.workspace),\n      '#',\n      '^'\n    ),\n\n    /**\n     * always jump to the closing bracket, but jump back the cursor when commit\n     * by alias divider `|` and section divider `#`\n     * See https://github.com/foambubble/foam/issues/962,\n     */\n    vscode.commands.registerCommand(\n      COMPLETION_CURSOR_MOVE.command,\n      async () => {\n        const activeEditor = vscode.window.activeTextEditor;\n        const document = activeEditor.document;\n        const currentPosition = activeEditor.selection.active;\n        const cursorChange = vscode.window.onDidChangeTextEditorSelection(\n          async e => {\n            const changedPosition = e.selections[0].active;\n            const preChar = document\n              .lineAt(changedPosition.line)\n              .text.charAt(changedPosition.character - 1);\n\n            const { character: selectionChar, line: selectionLine } =\n              e.selections[0].active;\n\n            const { line: completionLine, character: completionChar } =\n              currentPosition;\n\n            const inCompleteBySectionDivider =\n              linkCommitCharacters.includes(preChar) &&\n              selectionLine === completionLine &&\n              selectionChar === completionChar + 1;\n\n            cursorChange.dispose();\n            if (inCompleteBySectionDivider) {\n              await vscode.commands.executeCommand('cursorMove', {\n                to: 'left',\n                by: 'character',\n                value: 2,\n              });\n            }\n          }\n        );\n\n        await vscode.commands.executeCommand('cursorMove', {\n          to: 'right',\n          by: 'character',\n          value: 2,\n        });\n      }\n    )\n  );\n}\n\nexport class SectionCompletionProvider\n  implements vscode.CompletionItemProvider<vscode.CompletionItem>\n{\n  constructor(private ws: FoamWorkspace) {}\n\n  provideCompletionItems(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {\n    const cursorPrefix = document\n      .lineAt(position)\n      .text.substr(0, position.character);\n\n    // Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.\n    // See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.\n    const match = cursorPrefix.match(SECTION_REGEX);\n\n    if (!match) {\n      return null;\n    }\n\n    const resourceId =\n      match[1] === '#' ? fromVsCodeUri(document.uri) : match[1].slice(0, -1);\n\n    const resource = this.ws.find(resourceId);\n    const replacementRange = new vscode.Range(\n      position.line,\n      cursorPrefix.lastIndexOf('#') + 1,\n      position.line,\n      position.character\n    );\n    if (resource) {\n      const fragmentSoFar = cursorPrefix.slice(\n        cursorPrefix.lastIndexOf('#') + 1\n      );\n      if (fragmentSoFar.startsWith('^')) {\n        // Block anchor completions\n        const items = resource.blocks.map(b => {\n          const label = `^${b.id}`;\n          const item = new ResourceCompletionItem(\n            label,\n            vscode.CompletionItemKind.Text,\n            resource.uri.with({ fragment: `^${b.id}` })\n          );\n          item.detail = b.type;\n          item.sortText = String(b.range.start.line).padStart(5, '0');\n          item.range = replacementRange;\n          item.commitCharacters = sectionCommitCharacters;\n          item.command = COMPLETION_CURSOR_MOVE;\n          return item;\n        });\n        return new vscode.CompletionList(items);\n      }\n      // Section completions\n      const items = resource.sections.map(b => {\n        const item = new ResourceCompletionItem(\n          b.label,\n          vscode.CompletionItemKind.Text,\n          resource.uri.with({ fragment: b.label })\n        );\n        item.sortText = String(b.range.start.line).padStart(5, '0');\n        item.range = replacementRange;\n        item.commitCharacters = sectionCommitCharacters;\n        item.command = COMPLETION_CURSOR_MOVE;\n        return item;\n      });\n      return new vscode.CompletionList(items);\n    }\n  }\n\n  resolveCompletionItem(\n    item: ResourceCompletionItem | vscode.CompletionItem\n  ): vscode.ProviderResult<vscode.CompletionItem> {\n    if (item instanceof ResourceCompletionItem) {\n      return this.ws.readAsMarkdown(item.resourceUri).then(text => {\n        item.documentation = getNoteTooltip(text);\n        return item;\n      });\n    }\n    return item;\n  }\n}\n\nexport class WikilinkCompletionProvider\n  implements vscode.CompletionItemProvider<vscode.CompletionItem>\n{\n  constructor(private ws: FoamWorkspace, private graph: FoamGraph) {}\n\n  provideCompletionItems(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {\n    const cursorPrefix = document\n      .lineAt(position)\n      .text.substr(0, position.character);\n\n    // Requires autocomplete only if cursorPrefix matches `[[` that NOT ended by `]]`.\n    // See https://github.com/foambubble/foam/pull/596#issuecomment-825748205 for details.\n    const requiresAutocomplete = cursorPrefix.match(WIKILINK_REGEX);\n    if (!requiresAutocomplete || requiresAutocomplete[0].indexOf('#') >= 0) {\n      return null;\n    }\n\n    const text = requiresAutocomplete[0];\n    const labelStyle = getCompletionLabelSetting();\n    const aliasSetting = getCompletionAliasSetting();\n    const linkFormat = getCompletionLinkFormatSetting();\n\n    // Use safe range that VS Code accepts - replace content inside brackets only\n    const replacementRange = new vscode.Range(\n      position.line,\n      position.character - (text.length - 2),\n      position.line,\n      position.character\n    );\n\n    const directoryMode = getDirectoryModeSetting();\n\n    const resources = this.ws.list().map(resource => {\n      const resourceIsDocument =\n        ['attachment', 'image'].indexOf(resource.type) === -1;\n\n      // For index files (index.md, README.md), prefer the directory name as the completion\n      // identifier (e.g. [[bar]] instead of [[bar/index]]), unless a regular file already\n      // claims that name (e.g. bar.md exists), in which case fall back to the file identifier.\n      const directoryIdentifier =\n        resourceIsDocument && directoryMode === 'resolve'\n          ? this.ws.getDirectoryIdentifier(resource.uri)\n          : null;\n      const identifier =\n        directoryIdentifier && !this.ws.find(directoryIdentifier)\n          ? directoryIdentifier\n          : this.ws.getIdentifier(resource.uri);\n\n      const label = !resourceIsDocument\n        ? identifier\n        : labelStyle === 'path'\n        ? vscode.workspace.asRelativePath(toVsCodeUri(resource.uri))\n        : labelStyle === 'title'\n        ? resource.title\n        : identifier;\n\n      const item = new ResourceCompletionItem(\n        label,\n        vscode.CompletionItemKind.File,\n        resource.uri\n      );\n\n      item.detail = vscode.workspace.asRelativePath(toVsCodeUri(resource.uri));\n      item.sortText = resourceIsDocument\n        ? `0-${item.label}`\n        : `1-${item.label}`;\n\n      const useAlias =\n        resourceIsDocument &&\n        linkFormat !== 'link' &&\n        aliasSetting !== 'never' &&\n        wikilinkRequiresAlias(resource, this.ws.defaultExtension);\n\n      item.insertText = useAlias\n        ? `${identifier}|${resource.title}`\n        : identifier;\n      // When using aliases or markdown link format, don't allow commit characters\n      // since we either have the full text or will convert it\n      item.commitCharacters =\n        useAlias || linkFormat === 'link' ? [] : linkCommitCharacters;\n      item.range = replacementRange;\n      item.command =\n        linkFormat === 'link'\n          ? CONVERT_WIKILINK_TO_MDLINK\n          : COMPLETION_CURSOR_MOVE;\n      return item;\n    });\n    const aliases = this.ws.list().flatMap(resource =>\n      resource.aliases.map(a => {\n        const item = new ResourceCompletionItem(\n          a.title,\n          vscode.CompletionItemKind.Reference,\n          resource.uri\n        );\n\n        const identifier = this.ws.getIdentifier(resource.uri);\n\n        item.insertText = `${identifier}|${a.title}`;\n        // When using markdown link format, don't allow commit characters\n        item.commitCharacters =\n          linkFormat === 'link' ? [] : aliasCommitCharacters;\n        item.range = replacementRange;\n\n        // If link format is enabled, convert after completion\n        item.command =\n          linkFormat === 'link'\n            ? {\n                command: CONVERT_WIKILINK_TO_MDLINK.command,\n                title: CONVERT_WIKILINK_TO_MDLINK.title,\n              }\n            : COMPLETION_CURSOR_MOVE;\n\n        item.detail = `Alias of ${vscode.workspace.asRelativePath(\n          toVsCodeUri(resource.uri)\n        )}`;\n        return item;\n      })\n    );\n    const placeholders = Array.from(this.graph.placeholders.values()).map(\n      uri => {\n        const item = new vscode.CompletionItem(\n          uri.path,\n          vscode.CompletionItemKind.Interface\n        );\n        item.insertText = uri.path;\n        item.command = COMPLETION_CURSOR_MOVE;\n        item.range = replacementRange;\n        return item;\n      }\n    );\n\n    return new vscode.CompletionList([\n      ...resources,\n      ...aliases,\n      ...placeholders,\n    ]);\n  }\n\n  resolveCompletionItem(\n    item: ResourceCompletionItem | vscode.CompletionItem\n  ): vscode.ProviderResult<vscode.CompletionItem> {\n    if (item instanceof ResourceCompletionItem) {\n      return this.ws.readAsMarkdown(item.resourceUri).then(text => {\n        item.documentation = getNoteTooltip(text);\n        return item;\n      });\n    }\n    return item;\n  }\n}\n\n/**\n * A CompletionItem related to a Resource\n */\nclass ResourceCompletionItem extends vscode.CompletionItem {\n  constructor(\n    label: string,\n    type: vscode.CompletionItemKind,\n    public resourceUri: URI\n  ) {\n    super(label, type);\n  }\n}\n\nfunction getCompletionLabelSetting() {\n  const labelStyle: 'path' | 'title' | 'identifier' =\n    getFoamVsCodeConfig('completion.label');\n  return labelStyle;\n}\n\nfunction getCompletionAliasSetting() {\n  const aliasStyle: 'never' | 'whenPathDiffersFromTitle' = getFoamVsCodeConfig(\n    'completion.useAlias'\n  );\n  return aliasStyle;\n}\n\nfunction getCompletionLinkFormatSetting() {\n  const linkFormat: 'wikilink' | 'link' = getFoamVsCodeConfig(\n    'completion.linkFormat'\n  );\n  return linkFormat;\n}\n\nconst normalize = (text: string) => text.toLocaleLowerCase().trim();\nfunction wikilinkRequiresAlias(resource: Resource, defaultExtension: string) {\n  // Compare filename (without extension) to title\n  const nameWithoutExt = resource.uri.getName();\n  const titleWithoutExt = resource.title.endsWith(defaultExtension)\n    ? resource.title.slice(0, -defaultExtension.length)\n    : resource.title;\n  return normalize(nameWithoutExt) !== normalize(titleWithoutExt);\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/navigation-provider.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { createTestWorkspace } from '../test/test-utils';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { NavigationProvider } from './navigation-provider';\nimport { toVsCodeUri } from '../utils/vsc-utils';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { FoamGraph } from '../core/model/graph';\nimport { commandAsURI } from '../utils/commands';\nimport { CREATE_NOTE_COMMAND } from './commands/create-note';\nimport { Location } from '../core/model/location';\nimport { FoamTags } from '../core/model/tags';\n\ndescribe('Document navigation', () => {\n  const parser = createMarkdownParser([]);\n\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n  describe('Document links provider', () => {\n    it('should not return any link for empty documents', async () => {\n      const { uri, content } = await createFile('');\n      const ws = createTestWorkspace().set(parser.parse(uri, content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      expect(links.length).toEqual(0);\n    });\n\n    it('should not return any link for documents without links', async () => {\n      const { uri, content } = await createFile(\n        'This is some content without links'\n      );\n      const ws = createTestWorkspace().set(parser.parse(uri, content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      expect(links.length).toEqual(0);\n    });\n\n    it('should not create links for wikilinks, as this is managed by the definition provider', async () => {\n      const fileA = await createFile('# File A', ['file-a.md']);\n      const fileB = await createFile(`this is a link to [[${fileA.name}]].`);\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileA.uri, fileB.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      expect(links.length).toEqual(0);\n    });\n\n    it('should create links for placeholders', async () => {\n      const fileA = await createFile(`this is a link to [[a placeholder]].`);\n      const noteA = parser.parse(fileA.uri, fileA.content);\n      const ws = createTestWorkspace().set(noteA);\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      expect(links.length).toEqual(1);\n      expect(links[0].target).toEqual(\n        commandAsURI(\n          CREATE_NOTE_COMMAND.forPlaceholder(\n            Location.forObjectWithRange(noteA.uri, noteA.links[0]),\n            '.md',\n            {\n              onFileExists: 'open',\n            }\n          )\n        )\n      );\n      expect(links[0].range).toEqual(new vscode.Range(0, 20, 0, 33));\n    });\n\n    it('should not create a \"create note\" link for a direct path link targeting an existing file not indexed in workspace', async () => {\n      await createFile('some content', ['.editorconfig']);\n      const noteA = await createFile(`link to [config](./.editorconfig).`);\n      // Note: .editorconfig is NOT added to workspace (no provider supports extensionless files)\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      // The link should not be treated as a \"create note\" placeholder\n      expect(links.length).toEqual(0);\n    });\n\n    it('should not create a \"create note\" link for an absolute path link targeting an existing file not indexed in workspace', async () => {\n      const existingFile = await createFile('some content', ['.editorconfig']);\n      const absolutePath = existingFile.uri.toFsPath();\n      const noteA = await createFile(`link to [config](${absolutePath}).`);\n      // Note: .editorconfig is NOT added to workspace (no provider supports extensionless files)\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      // The link should not be treated as a \"create note\" placeholder\n      expect(links.length).toEqual(0);\n    });\n\n    it('should not create a \"create note\" link for a direct path link targeting a file in .foam directory (excluded from workspace indexing)', async () => {\n      const template = await createFile('Template content', [\n        '.foam',\n        'templates',\n        'template.md',\n      ]);\n      const noteA = await createFile(\n        `link to [template](.foam/templates/template.md).`\n      );\n      // Note: template is NOT added to workspace because .foam/** is excluded from indexing\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const links = await provider.provideDocumentLinks(doc);\n\n      // The link should not be treated as a \"create note\" placeholder\n      expect(links.length).toEqual(0);\n    });\n  });\n\n  describe('definition provider', () => {\n    it('should not create a definition for a placeholder', async () => {\n      const fileA = await createFile(`this is a link to [[placeholder]].`);\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 22)\n      );\n\n      expect(definitions).toBeUndefined();\n    });\n    it('should create a definition for a wikilink', async () => {\n      const fileA = await createFile('# File A');\n      const fileB = await createFile(`this is a link to [[${fileA.name}]].`);\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 22)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));\n      // target the beginning of the file\n      expect(definitions[0].targetRange).toEqual(new vscode.Range(0, 0, 0, 0));\n      // select nothing\n      expect(definitions[0].targetSelectionRange).toEqual(\n        new vscode.Range(0, 0, 0, 0)\n      );\n    });\n\n    it('should create a definition for a regular link', async () => {\n      const fileA = await createFile('# File A');\n      const fileB = await createFile(\n        `this is a link to [a file](./${fileA.base}).`\n      );\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 22)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));\n    });\n\n    it('should create a definition for a direct path link targeting an existing file not indexed in workspace', async () => {\n      const existingFile = await createFile('some content', ['.editorconfig']);\n      const noteA = await createFile(`link to [config](./.editorconfig).`);\n      // Note: existingFile is NOT added to workspace (no provider supports extensionless files)\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      // Position within the link text: \"link to [config](./.editorconfig).\"\n      //                                          ^col 9\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions).toBeDefined();\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(existingFile.uri));\n    });\n\n    it('should create a definition for an absolute path link targeting an existing file not indexed in workspace', async () => {\n      const existingFile = await createFile('some content', ['.editorconfig']);\n      const absolutePath = existingFile.uri.toFsPath();\n      const noteA = await createFile(`link to [config](${absolutePath}).`);\n      // Note: existingFile is NOT added to workspace (no provider supports extensionless files)\n      const ws = createTestWorkspace().set(\n        parser.parse(noteA.uri, noteA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(noteA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions).toBeDefined();\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(existingFile.uri));\n    });\n\n    it('should support wikilinks that have an alias', async () => {\n      const fileA = await createFile(\"# File A that's aliased\");\n      const fileB = await createFile(\n        `this is a link to [[${fileA.name}|alias]].`\n      );\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 22)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));\n    });\n\n    it('should support wikilink aliases in tables using escape character', async () => {\n      const fileA = await createFile('# File that has to be aliased');\n      const fileB = await createFile(`\n  | Col A | ColB |\n  | --- | --- |\n  | [[${fileA.name}\\\\|alias]] | test |\n    `);\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(3, 10)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(fileA.uri));\n    });\n  });\n\n  describe('directory link navigation', () => {\n    it('should navigate [[bar]] to bar/index.md', async () => {\n      const index = await createFile('# Bar Index', ['bar', 'index.md']);\n      const note = await createFile(`link to [[bar]]`);\n      const ws = createTestWorkspace()\n        .set(parser.parse(index.uri, index.content))\n        .set(parser.parse(note.uri, note.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(note.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(index.uri));\n    });\n\n    it('should navigate [label](bar) to bar/index.md', async () => {\n      const index = await createFile('# Bar Index', ['bar', 'index.md']);\n      const note = await createFile(`link to [bar](bar)`);\n      const ws = createTestWorkspace()\n        .set(parser.parse(index.uri, index.content))\n        .set(parser.parse(note.uri, note.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(note.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(index.uri));\n    });\n\n    it('should navigate [label](bar/) (trailing slash) to bar/index.md', async () => {\n      const index = await createFile('# Bar Index', ['bar', 'index.md']);\n      const note = await createFile(`link to [bar](bar/)`);\n      const ws = createTestWorkspace()\n        .set(parser.parse(index.uri, index.content))\n        .set(parser.parse(note.uri, note.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(note.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n      const definitions = await provider.provideDefinition(\n        doc,\n        new vscode.Position(0, 10)\n      );\n\n      expect(definitions.length).toEqual(1);\n      expect(definitions[0].targetUri).toEqual(toVsCodeUri(index.uri));\n    });\n  });\n\n  describe('reference provider', () => {\n    it('should provide references for wikilinks', async () => {\n      const fileA = await createFile('The content of File A');\n      const fileB = await createFile(\n        `File B is connected to [[${fileA.name}]] and has a [[placeholder]].`\n      );\n      const fileC = await createFile(\n        `File C is also connected to [[${fileA.name}]].`\n      );\n      const fileD = await createFile(`File C has a [[placeholder]].`);\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content))\n        .set(parser.parse(fileC.uri, fileC.content))\n        .set(parser.parse(fileD.uri, fileD.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileB.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      const refs = await provider.provideReferences(\n        doc,\n        new vscode.Position(0, 26)\n      );\n\n      // Make sure the references are sorted by position, so we match the right expectation\n      refs.sort((a, b) => a.range.start.character - b.range.start.character);\n\n      expect(refs.length).toEqual(2);\n      expect(refs[0]).toEqual({\n        uri: toVsCodeUri(fileB.uri),\n        range: new vscode.Range(0, 23, 0, 23 + 9),\n      });\n    });\n\n    it('should provide references for tags', async () => {\n      const fileA = await createFile('This file has #tag1 and #tag2.');\n      const fileB = await createFile(\n        'This file also has #tag1 and other content.'\n      );\n      const fileC = await createFile('This file has #tag2 and #tag3.');\n\n      const ws = createTestWorkspace()\n        .set(parser.parse(fileA.uri, fileA.content))\n        .set(parser.parse(fileB.uri, fileB.content))\n        .set(parser.parse(fileC.uri, fileC.content));\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      // Test references for #tag1 (position 15 is within the #tag1 text)\n      const tag1Refs = await provider.provideReferences(\n        doc,\n        new vscode.Position(0, 15)\n      );\n\n      expect(tag1Refs.length).toEqual(2); // #tag1 appears in fileA and fileB\n\n      const refUris = tag1Refs.map(ref => ref.uri);\n      expect(refUris).toContainEqual(toVsCodeUri(fileA.uri));\n      expect(refUris).toContainEqual(toVsCodeUri(fileB.uri));\n    });\n\n    it('should provide references for tags with different positions', async () => {\n      const fileA = await createFile(\n        'Multiple #same-tag mentions #same-tag here.'\n      );\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      // Test references for #same-tag (clicking on first occurrence)\n      const refs = await provider.provideReferences(\n        doc,\n        new vscode.Position(0, 10) // Position within first #same-tag\n      );\n\n      expect(refs.length).toEqual(2); // Both occurrences of #same-tag\n\n      // Verify both ranges are correct\n      const sortedRefs = refs.sort(\n        (a, b) => a.range.start.character - b.range.start.character\n      );\n\n      // First occurrence: \"Multiple #same-tag mentions\"\n      expect(sortedRefs[0].range.start.character).toBeLessThan(\n        sortedRefs[1].range.start.character\n      );\n    });\n\n    it('should not provide references when position is not on a tag', async () => {\n      const fileA = await createFile('This file has #tag1 and normal text.');\n\n      const ws = createTestWorkspace().set(\n        parser.parse(fileA.uri, fileA.content)\n      );\n      const graph = FoamGraph.fromWorkspace(ws);\n      const tags = FoamTags.fromWorkspace(ws);\n\n      const { doc } = await showInEditor(fileA.uri);\n      const provider = new NavigationProvider(ws, graph, parser, tags);\n\n      // Position on \"normal text\" (not on a tag or link)\n      const refs = await provider.provideReferences(\n        doc,\n        new vscode.Position(0, 30)\n      );\n\n      expect(refs).toBeUndefined();\n    });\n\n    it.todo('should provide references for placeholders');\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/navigation-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { toVsCodeRange, toVsCodeUri, fromVsCodeUri } from '../utils/vsc-utils';\nimport { Foam } from '../core/model/foam';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport {\n  Block,\n  Resource,\n  ResourceLink,\n  ResourceParser,\n  Section,\n} from '../core/model/note';\nimport { URI } from '../core/model/uri';\nimport { Range } from '../core/model/range';\nimport { FoamGraph } from '../core/model/graph';\nimport { Position } from '../core/model/position';\nimport { CREATE_NOTE_COMMAND } from './commands/create-note';\nimport { commandAsURI } from '../utils/commands';\nimport { Location } from '../core/model/location';\nimport { fileExists, getFoamDocSelectors } from '../services/editor';\nimport { FoamTags } from '../core/model/tags';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  const navigationProvider = new NavigationProvider(\n    foam.workspace,\n    foam.graph,\n    foam.services.parser,\n    foam.tags\n  );\n\n  context.subscriptions.push(\n    vscode.languages.registerDefinitionProvider(\n      getFoamDocSelectors(),\n      navigationProvider\n    ),\n    vscode.languages.registerDocumentLinkProvider(\n      getFoamDocSelectors(),\n      navigationProvider\n    ),\n    vscode.languages.registerReferenceProvider(\n      getFoamDocSelectors(),\n      navigationProvider\n    )\n  );\n}\n\n/**\n * Provides navigation and references for Foam links.\n * - We create definintions for existing wikilinks but not placeholders\n * - We create links for both\n * - We create references for both\n *\n * Placeholders are created as links so that when clicking on them a new note will be created.\n * Definitions are automatically invoked by VS Code on hover, whereas links require\n * the user to explicitly clicking - and we want the note creation to be explicit.\n *\n * Also see https://github.com/foambubble/foam/pull/724\n */\nexport class NavigationProvider\n  implements\n    vscode.DefinitionProvider,\n    vscode.DocumentLinkProvider,\n    vscode.ReferenceProvider\n{\n  constructor(\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    private parser: ResourceParser,\n    private tags: FoamTags\n  ) {}\n\n  /**\n   * Provide references for links, placeholders, and tags\n   */\n  public provideReferences(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.Location[]> {\n    const resource = this.parser.parse(\n      fromVsCodeUri(document.uri),\n      document.getText()\n    );\n\n    // Check if position is on a tag first\n    const targetTag = resource.tags.find(tag =>\n      Range.containsPosition(tag.range, position)\n    );\n    if (targetTag) {\n      return this.getTagReferences(targetTag.label);\n    }\n\n    // Check if position is on a link\n    const targetLink: ResourceLink | undefined = resource.links.find(link =>\n      Range.containsPosition(link.range, position)\n    );\n    if (targetLink) {\n      const uri = this.workspace.resolveLink(resource, targetLink);\n      return this.graph\n        .getBacklinks(uri)\n        .map(\n          connection =>\n            new vscode.Location(\n              toVsCodeUri(connection.source),\n              toVsCodeRange(connection.link.range)\n            )\n        );\n    }\n\n    return;\n  }\n\n  /**\n   * Get all references for a given tag label across the workspace\n   */\n  private getTagReferences(tagLabel: string): vscode.Location[] {\n    const references: vscode.Location[] = [];\n    const tagLocations = this.tags.tags.get(tagLabel) ?? [];\n    for (const tagLocation of tagLocations) {\n      references.push(\n        new vscode.Location(\n          toVsCodeUri(tagLocation.uri),\n          toVsCodeRange(tagLocation.range)\n        )\n      );\n    }\n    return references;\n  }\n\n  /**\n   * Create definitions for resolved links\n   */\n  public async provideDefinition(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): Promise<vscode.LocationLink[]> {\n    const resource = this.parser.parse(\n      fromVsCodeUri(document.uri),\n      document.getText()\n    );\n    const targetLink: ResourceLink | undefined = resource.links.find(link =>\n      Range.containsPosition(link.range, position)\n    );\n    if (!targetLink) {\n      return;\n    }\n\n    const uri = this.workspace.resolveLink(resource, targetLink);\n    if (uri.isPlaceholder()) {\n      // For direct path links, check if the file actually exists on disk even\n      // though it's not indexed in the workspace (e.g. extensionless files like\n      // .editorconfig that no provider recognises). If so, open it directly.\n      // See: https://github.com/foambubble/foam/issues/1379\n      if (targetLink.type === 'link') {\n        const realUri = uri.with({ scheme: 'file' });\n        if (await fileExists(realUri)) {\n          return [\n            {\n              originSelectionRange: new vscode.Range(\n                targetLink.range.start.line,\n                targetLink.range.start.character,\n                targetLink.range.end.line,\n                targetLink.range.end.character\n              ),\n              targetUri: toVsCodeUri(realUri),\n              targetRange: new vscode.Range(0, 0, 0, 0),\n              targetSelectionRange: new vscode.Range(0, 0, 0, 0),\n            },\n          ];\n        }\n      }\n      return;\n    }\n\n    const targetResource = this.workspace.get(uri);\n    const fragmentRange = resolveFragmentRange(targetResource, uri.fragment);\n\n    const targetRange =\n      fragmentRange ??\n      Range.createFromPosition(Position.create(0, 0), Position.create(0, 0));\n    const targetSelectionRange =\n      fragmentRange ?? Range.createFromPosition(targetRange.start);\n\n    const result: vscode.LocationLink = {\n      originSelectionRange: new vscode.Range(\n        targetLink.range.start.line,\n        targetLink.range.start.character +\n          (targetLink.type === 'wikilink' ? 2 : 0),\n        targetLink.range.end.line,\n        targetLink.range.end.character -\n          (targetLink.type === 'wikilink' ? 2 : 0)\n      ),\n      targetUri: toVsCodeUri(uri.asPlain()),\n      targetRange: toVsCodeRange(targetRange),\n      targetSelectionRange: toVsCodeRange(targetSelectionRange),\n    };\n    return [result];\n  }\n\n  /**\n   * Create links for wikilinks and placeholders\n   */\n  public async provideDocumentLinks(\n    document: vscode.TextDocument\n  ): Promise<vscode.DocumentLink[]> {\n    const documentUri = fromVsCodeUri(document.uri);\n    const resource = this.parser.parse(documentUri, document.getText());\n\n    const targets: { link: ResourceLink; target: URI }[] = resource.links.map(\n      link => ({\n        link,\n        target: this.workspace.resolveLink(resource, link),\n      })\n    );\n\n    const placeholders = targets.filter(o => o.target.isPlaceholder()); // links to resources are managed by the definition provider\n\n    const links: vscode.DocumentLink[] = (\n      await Promise.all(\n        placeholders.map(async o => {\n          // For direct path links, skip if the file actually exists on disk but\n          // isn't indexed (e.g. extensionless dotfiles). VS Code handles them natively.\n          // See: https://github.com/foambubble/foam/issues/1379\n          if (o.link.type === 'link') {\n            const realUri = o.target.with({ scheme: 'file' });\n            if (await fileExists(realUri)) {\n              return null;\n            }\n          }\n\n          const command = CREATE_NOTE_COMMAND.forPlaceholder(\n            Location.forObjectWithRange(documentUri, o.link),\n            this.workspace.defaultExtension,\n            {\n              onFileExists: 'open',\n            }\n          );\n\n          const documentLink = new vscode.DocumentLink(\n            new vscode.Range(\n              o.link.range.start.line,\n              o.link.range.start.character + 2,\n              o.link.range.end.line,\n              o.link.range.end.character - 2\n            ),\n            commandAsURI(command)\n          );\n          documentLink.tooltip = `Create note for '${o.target.path}'`;\n          return documentLink;\n        })\n      )\n    ).filter(Boolean);\n\n    const tags: vscode.DocumentLink[] = resource.tags.map(tag => {\n      const command = {\n        name: 'foam-vscode.views.tags-explorer.focus',\n        params: [tag.label, documentUri],\n      };\n\n      const documentLink = new vscode.DocumentLink(\n        new vscode.Range(\n          tag.range.start.line,\n          tag.range.start.character,\n          tag.range.end.line,\n          tag.range.end.character\n        ),\n        commandAsURI(command)\n      );\n      documentLink.tooltip = `Explore tag '${tag.label}'`;\n      return documentLink;\n    });\n\n    return links.concat(tags);\n  }\n}\n\n/**\n * Returns the Range for a URI fragment, handling both section links\n * (`#Heading`) and block anchor links (`#^blockid`).\n * Returns null if the fragment is empty or not found.\n */\nfunction resolveFragmentRange(\n  resource: Resource,\n  fragment: string\n): (Section | Block)['range'] | null {\n  if (!fragment) {\n    return null;\n  }\n  if (fragment.startsWith('^')) {\n    return Resource.findBlock(resource, fragment.slice(1))?.range ?? null;\n  }\n  return Resource.findSection(resource, fragment)?.range ?? null;\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/connections.spec.ts",
    "content": "/* @unit-ready */\nimport { workspace, window } from 'vscode';\nimport { createTestNote, createTestWorkspace } from '../../test/test-utils';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createNote,\n  getUriInWorkspace,\n} from '../../test/test-utils-vscode';\nimport { ConnectionsTreeDataProvider } from './connections';\nimport { MapBasedMemento, toVsCodeUri } from '../../utils/vsc-utils';\nimport { FoamGraph } from '../../core/model/graph';\nimport {\n  ResourceRangeTreeItem,\n  ResourceTreeItem,\n} from './utils/tree-view-utils';\n\ndescribe('Backlinks panel', () => {\n  beforeAll(async () => {\n    await cleanWorkspace();\n    await createNote(noteA);\n    await createNote(noteB);\n    await createNote(noteC);\n  });\n\n  // TODO: this should really just be the workspace folder, use that once #806 is fixed\n  const rootUri = getUriInWorkspace('just-a-ref.md');\n  const ws = createTestWorkspace();\n\n  const noteA = createTestNote({\n    root: rootUri,\n    uri: './note-a.md',\n  });\n  const noteB = createTestNote({\n    root: rootUri,\n    uri: './note-b.md',\n    links: [{ slug: 'note-a' }, { slug: 'note-a#section' }],\n  });\n  const noteC = createTestNote({\n    root: rootUri,\n    uri: './note-c.md',\n    links: [{ slug: 'note-a' }],\n  });\n  ws.set(noteA).set(noteB).set(noteC);\n  const graph = FoamGraph.fromWorkspace(ws, true);\n\n  const provider = new ConnectionsTreeDataProvider(\n    ws,\n    graph,\n    new MapBasedMemento(),\n    false\n  );\n\n  afterAll(async () => {\n    graph.dispose();\n    ws.dispose();\n    provider.dispose();\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n    provider.target = undefined;\n  });\n\n  it.skip('targets active editor', async () => {\n    const docA = await workspace.openTextDocument(toVsCodeUri(noteA.uri));\n    const docB = await workspace.openTextDocument(toVsCodeUri(noteB.uri));\n\n    await window.showTextDocument(docA);\n    expect(provider.target).toEqual(noteA.uri);\n\n    await window.showTextDocument(docB);\n    expect(provider.target).toEqual(noteB.uri);\n  });\n\n  it('shows linking resources alphaetically by name', async () => {\n    provider.target = noteA.uri;\n    await provider.refresh();\n    const notes = (await provider.getChildren()) as ResourceTreeItem[];\n    expect(notes.map(n => n.resource.uri.path)).toEqual([\n      noteB.uri.path,\n      noteC.uri.path,\n    ]);\n  });\n  it('shows references in range order', async () => {\n    provider.target = noteA.uri;\n    await provider.refresh();\n    const notes = (await provider.getChildren()) as ResourceTreeItem[];\n    const linksFromB = (await provider.getChildren(\n      notes[0]\n    )) as ResourceRangeTreeItem[];\n    expect(linksFromB.map(l => l.range)).toEqual(\n      noteB.links\n        .map(l => l.range)\n        .sort((a, b) => a.start.character - b.start.character)\n    );\n  });\n  it('navigates to the document if clicking on note', async () => {\n    provider.target = noteA.uri;\n    await provider.refresh();\n    const notes = (await provider.getChildren()) as ResourceTreeItem[];\n    expect(notes[0].command).toMatchObject({\n      command: 'vscode.open',\n      arguments: [expect.objectContaining({ path: noteB.uri.path })],\n    });\n    const links = (await provider.getChildren(notes[0])) as ResourceTreeItem[];\n    expect(links[0].command).toMatchObject({\n      command: 'vscode.open',\n      arguments: [\n        expect.objectContaining({ path: noteB.uri.path }),\n        expect.objectContaining({ selection: expect.anything() }),\n      ],\n    });\n  });\n  it('navigates to document with link selection if clicking on backlink', async () => {\n    provider.target = noteA.uri;\n    await provider.refresh();\n    const notes = (await provider.getChildren()) as ResourceTreeItem[];\n    const linksFromB = (await provider.getChildren(\n      notes[0]\n    )) as ResourceRangeTreeItem[];\n    expect(linksFromB[0].command).toMatchObject({\n      command: 'vscode.open',\n      arguments: [\n        expect.objectContaining({ path: noteB.uri.path }),\n        {\n          selection: expect.arrayContaining([]),\n        },\n      ],\n    });\n  });\n  it('refreshes upon changes in the workspace', async () => {\n    let notes: ResourceTreeItem[] = [];\n    provider.target = noteA.uri;\n    await provider.refresh();\n    notes = (await provider.getChildren()) as ResourceTreeItem[];\n    expect(notes.length).toEqual(2);\n\n    const noteD = createTestNote({\n      root: rootUri,\n      uri: './note-d.md',\n    });\n    ws.set(noteD);\n    await provider.refresh();\n    notes = (await provider.getChildren()) as ResourceTreeItem[];\n    expect(notes.length).toEqual(2);\n\n    const noteDBis = createTestNote({\n      root: rootUri,\n      uri: './note-d.md',\n      links: [{ slug: 'note-a' }],\n    });\n    ws.set(noteDBis);\n    await provider.refresh();\n    notes = (await provider.getChildren()) as ResourceTreeItem[];\n    expect(notes.map(n => n.resource.uri.path)).toEqual(\n      [noteB.uri, noteC.uri, noteD.uri].map(uri => uri.path)\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/connections.ts",
    "content": "import * as vscode from 'vscode';\nimport { URI } from '../../core/model/uri';\nimport { Foam } from '../../core/model/foam';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { Connection, FoamGraph } from '../../core/model/graph';\nimport { Range } from '../../core/model/range';\nimport { ContextMemento, fromVsCodeUri } from '../../utils/vsc-utils';\nimport {\n  BaseTreeItem,\n  ResourceRangeTreeItem,\n  ResourceTreeItem,\n  UriTreeItem,\n  createConnectionItemsForResource,\n} from './utils/tree-view-utils';\nimport { BaseTreeProvider } from './utils/base-tree-provider';\nimport { isNone } from '../../core/utils';\nimport { getWorkspaceDefaultScheme } from '../../services/editor';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  const provider = new ConnectionsTreeDataProvider(\n    foam.workspace,\n    foam.graph,\n    context.globalState\n  );\n  const treeView = vscode.window.createTreeView('foam-vscode.connections', {\n    treeDataProvider: provider,\n    showCollapseAll: true,\n  });\n\n  const updateTreeView = async () => {\n    provider.target = vscode.window.activeTextEditor\n      ? fromVsCodeUri(vscode.window.activeTextEditor?.document.uri)\n      : undefined;\n    await provider.refresh();\n  };\n\n  updateTreeView();\n\n  context.subscriptions.push(\n    provider,\n    treeView,\n    foam.graph.onDidUpdate(() => updateTreeView()),\n    vscode.window.onDidChangeActiveTextEditor(() => updateTreeView()),\n    provider.onDidChangeTreeData(() => {\n      treeView.title = ` ${provider.show.get()} (${provider.nValues})`;\n    })\n  );\n}\n\nexport class ConnectionsTreeDataProvider extends BaseTreeProvider<vscode.TreeItem> {\n  public show: ContextMemento<'all links' | 'backlinks' | 'forward links'>;\n  public target?: URI = undefined;\n  public nValues = 0;\n  private connectionItems: ResourceRangeTreeItem[] = [];\n\n  constructor(\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    public state: vscode.Memento,\n    registerCommands = true // for testing. don't love it, but will do for now\n  ) {\n    super();\n    this.show = new ContextMemento<'all links' | 'backlinks' | 'forward links'>(\n      this.state,\n      `foam-vscode.views.connections.show`,\n      'all links',\n      true\n    );\n    if (!registerCommands) {\n      return;\n    }\n    this.disposables.push(\n      vscode.commands.registerCommand(\n        `foam-vscode.views.connections.show:all-links`,\n        () => {\n          this.show.update('all links');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.connections.show:backlinks`,\n        () => {\n          this.show.update('backlinks');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.connections.show:forward-links`,\n        () => {\n          this.show.update('forward links');\n          this.refresh();\n        }\n      )\n    );\n  }\n\n  async refresh(): Promise<void> {\n    const uri = this.target;\n\n    const connectionItems =\n      isNone(uri) || isNone(this.workspace.find(uri))\n        ? []\n        : await createConnectionItemsForResource(\n            this.workspace,\n            this.graph,\n            uri,\n            (connection: Connection) => {\n              const isBacklink = connection.target\n                .asPlain()\n                .isEqual(this.target);\n              return (\n                this.show.get() === 'all links' ||\n                (isBacklink && this.show.get() === 'backlinks') ||\n                (!isBacklink && this.show.get() === 'forward links')\n              );\n            }\n          );\n\n    this.connectionItems = connectionItems;\n    this.nValues = connectionItems.length;\n    super.refresh();\n  }\n\n  async getChildren(item?: BacklinkPanelTreeItem): Promise<vscode.TreeItem[]> {\n    if (item && item instanceof BaseTreeItem) {\n      return item.getChildren();\n    }\n\n    const byResource = this.connectionItems.reduce((acc, item) => {\n      const connection = item.value as Connection;\n      const isBacklink = connection.target.asPlain().isEqual(this.target);\n      const uri = isBacklink ? connection.source : connection.target;\n      acc.set(uri.toString(), [...(acc.get(uri.toString()) ?? []), item]);\n      return acc;\n    }, new Map() as Map<string, ResourceRangeTreeItem[]>);\n\n    const resourceItems = [];\n    for (const [uriString, items] of byResource.entries()) {\n      const uri = URI.parse(uriString, getWorkspaceDefaultScheme());\n      const item = uri.isPlaceholder()\n        ? new UriTreeItem(uri, {\n            collapsibleState: vscode.TreeItemCollapsibleState.Expanded,\n          })\n        : new ResourceTreeItem(this.workspace.get(uri), this.workspace, {\n            collapsibleState: vscode.TreeItemCollapsibleState.Expanded,\n          });\n      const children = items.sort((a, b) => {\n        return (\n          a.variant.localeCompare(b.variant) || Range.isBefore(a.range, b.range)\n        );\n      });\n      item.getChildren = () => Promise.resolve(children);\n      item.description = `(${items.length}) ${item.description}`;\n      // item.iconPath = children.every(c => c.variant === children[0].variant)\n      //   ? children[0].iconPath\n      //   : new vscode.ThemeIcon(\n      //       'arrow-swap',\n      //       new vscode.ThemeColor('charts.purple')\n      //     );\n      resourceItems.push(item);\n    }\n    resourceItems.sort((a, b) => a.label.localeCompare(b.label));\n    return resourceItems;\n  }\n}\n\ntype BacklinkPanelTreeItem = ResourceTreeItem | ResourceRangeTreeItem;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/dataviz/index.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../../core/model/foam';\nimport { Logger } from '../../../core/utils/log';\nimport { fromVsCodeUri } from '../../../utils/vsc-utils';\nimport { isSome } from '../../../core/utils';\nimport { getFoamVsCodeConfig } from '../../../services/config';\nimport type { StylePayload } from './graph-protocol';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  let panel: vscode.WebviewPanel | undefined = undefined;\n  vscode.workspace.onDidChangeConfiguration(event => {\n    if (panel) {\n      if (event.affectsConfiguration('foam.graph.style')) {\n        const style = getGraphStyle();\n        panel.webview.postMessage({\n          type: 'didUpdateStyle',\n          payload: style,\n        });\n      }\n    }\n  });\n\n  vscode.commands.registerCommand('foam-vscode.show-graph', async () => {\n    if (panel) {\n      panel.reveal();\n    } else {\n      const foam = await foamPromise;\n      panel = await createGraphPanel(foam, context);\n      const onFoamChanged = _ => {\n        updateGraph(panel, foam);\n      };\n\n      const noteUpdatedListener = foam.graph.onDidUpdate(onFoamChanged);\n      panel.onDidDispose(() => {\n        noteUpdatedListener.dispose();\n        panel = undefined;\n      });\n\n      vscode.window.onDidChangeActiveTextEditor(e => {\n        if (e?.document?.uri?.scheme !== 'untitled') {\n          const note = foam.workspace.get(fromVsCodeUri(e.document.uri));\n          if (isSome(note)) {\n            panel.webview.postMessage({\n              type: 'didSelectNote',\n              payload: note.uri.path,\n            });\n          }\n        }\n      });\n    }\n  });\n  const shouldOpenGraphOnStartup = getFoamVsCodeConfig('graph.onStartup');\n  if (shouldOpenGraphOnStartup) {\n    vscode.commands.executeCommand('foam-vscode.show-graph');\n  }\n}\n\nfunction updateGraph(panel: vscode.WebviewPanel, foam: Foam) {\n  const graph = generateGraphData(foam);\n  panel.webview.postMessage({\n    type: 'didUpdateGraphData',\n    payload: graph,\n  });\n}\n\nfunction generateGraphData(foam: Foam) {\n  const graph = {\n    nodeInfo: {},\n    edges: new Set(),\n  };\n\n  foam.workspace.list().forEach(n => {\n    const type = n.type === 'note' ? n.properties.type ?? 'note' : n.type;\n    const title = n.type === 'note' ? n.title : n.uri.getBasename();\n    graph.nodeInfo[n.uri.path] = {\n      id: n.uri.path,\n      type: type,\n      uri: n.uri,\n      title: cutTitle(title),\n      properties: n.properties,\n      tags: n.tags,\n    };\n  });\n  foam.graph.getAllConnections().forEach(c => {\n    graph.edges.add({\n      source: c.source.path,\n      target: c.target.path,\n    });\n    if (c.target.isPlaceholder()) {\n      graph.nodeInfo[c.target.path] = {\n        id: c.target.path,\n        type: 'placeholder',\n        uri: c.target,\n        title: c.target.path,\n        properties: {},\n      };\n    }\n  });\n\n  return {\n    nodeInfo: graph.nodeInfo,\n    links: Array.from(graph.edges),\n  };\n}\n\nfunction cutTitle(title: string): string {\n  const maxLen = vscode.workspace\n    .getConfiguration('foam.graph')\n    .get('titleMaxLength', 24);\n  if (maxLen > 0 && title.length > maxLen) {\n    return title.substring(0, maxLen).concat('...');\n  }\n  return title;\n}\n\nasync function createGraphPanel(\n  foam: Foam,\n  context: vscode.ExtensionContext,\n  viewColumn?: vscode.ViewColumn\n) {\n  const panel = vscode.window.createWebviewPanel(\n    'foam-graph',\n    'Foam Graph',\n    viewColumn ?? vscode.ViewColumn.Beside,\n    {\n      enableScripts: true,\n      retainContextWhenHidden: true,\n    }\n  );\n\n  panel.webview.html = await getWebviewContent(context, panel);\n\n  panel.webview.onDidReceiveMessage(\n    async message => {\n      switch (message.type) {\n        case 'webviewDidLoad': {\n          const styles = getGraphStyle();\n          panel.webview.postMessage({\n            type: 'didUpdateStyle',\n            payload: styles,\n          });\n\n          updateGraph(panel, foam);\n          break;\n        }\n        case 'webviewDidSelectNode': {\n          const noteUri = vscode.Uri.parse(message.payload);\n          const selectedNote = foam.workspace.get(fromVsCodeUri(noteUri));\n\n          if (isSome(selectedNote)) {\n            const navigateToPreview = getFoamVsCodeConfig(\n              'graph.navigateToPreview',\n              false\n            );\n            const command = getNodeNavigationCommand(\n              noteUri.path,\n              navigateToPreview\n            );\n            if (command === 'markdown.showPreview') {\n              vscode.commands.executeCommand(command, noteUri);\n            } else {\n              vscode.commands.executeCommand(\n                command,\n                noteUri,\n                vscode.ViewColumn.One\n              );\n            }\n          }\n          break;\n        }\n        case 'error': {\n          Logger.error('An error occurred in the graph view', message.payload);\n          break;\n        }\n      }\n    },\n    undefined,\n    context.subscriptions\n  );\n\n  return panel;\n}\n\nasync function getWebviewContent(\n  context: vscode.ExtensionContext,\n  panel: vscode.WebviewPanel\n) {\n  const datavizUri = vscode.Uri.joinPath(\n    context.extensionUri,\n    'static',\n    'dataviz'\n  );\n  const getWebviewUri = (fileName: string) =>\n    panel.webview.asWebviewUri(vscode.Uri.joinPath(datavizUri, fileName));\n\n  const indexHtml = new TextDecoder('utf-8').decode(\n    await vscode.workspace.fs.readFile(\n      vscode.Uri.joinPath(datavizUri, 'index.html')\n    )\n  );\n\n  // Replace the script paths with the appropriate webview URI.\n  const filled = indexHtml.replace(\n    /data-replace (src|href)=\"[^\"]+\"/g,\n    match => {\n      const i = match.indexOf(' ');\n      const j = match.indexOf('=');\n      const uri = getWebviewUri(match.slice(j + 2, -1).trim());\n      return match.slice(i + 1, j) + '=\"' + uri.toString() + '\"';\n    }\n  );\n\n  return filled;\n}\n\nfunction getGraphStyle(): StylePayload {\n  return vscode.workspace.getConfiguration('foam.graph').get('style');\n}\n\nexport function getNodeNavigationCommand(\n  uriPath: string,\n  navigateToPreview: boolean\n): string {\n  if (navigateToPreview && uriPath.endsWith('.md')) {\n    return 'markdown.showPreview';\n  }\n  return 'vscode.open';\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/dataviz.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { closeEditors, createFile } from '../../test/test-utils-vscode';\nimport { wait } from '../../test/test-utils';\n\ndescribe('Graph Panel', () => {\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  afterEach(async () => {\n    await closeEditors();\n  });\n\n  it('should create graph beside active editor when panel does not exist', async () => {\n    const { uri: noteUri } = await createFile('# Note A', ['note-a.md']);\n\n    // Open a note in column 1\n    await vscode.window.showTextDocument(vscode.Uri.file(noteUri.toFsPath()), {\n      viewColumn: vscode.ViewColumn.One,\n    });\n\n    // Execute show-graph command\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n\n    // Wait a bit for the webview to be created\n    await wait(200);\n\n    // Find the graph panel - should be beside (to the right of) column 1\n    const graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    expect(graphPanel).toBeDefined();\n    // ViewColumn.Beside creates a new column to the right, so it should be > column 1\n    expect(graphPanel?.group.viewColumn).toBeGreaterThan(vscode.ViewColumn.One);\n  });\n\n  it('should create graph in ViewColumn.One when no active editor', async () => {\n    // Make sure no editors are open\n    await closeEditors();\n\n    // Execute show-graph command\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n\n    // Wait a bit for the webview to be created\n    await wait(200);\n\n    // Find the graph panel\n    const graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    expect(graphPanel).toBeDefined();\n    expect(graphPanel?.group.viewColumn).toBe(vscode.ViewColumn.One);\n  });\n\n  it('should reveal existing graph panel without moving it', async () => {\n    const { uri: noteUri } = await createFile('# Note A', ['note-a.md']);\n\n    // Open a note in column 1\n    await vscode.window.showTextDocument(vscode.Uri.file(noteUri.toFsPath()), {\n      viewColumn: vscode.ViewColumn.One,\n    });\n\n    // Create graph (should be beside column 1, so in column 2)\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n    await wait(200);\n\n    let graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    expect(graphPanel).toBeDefined();\n    const originalGraphColumn = graphPanel?.group.viewColumn;\n\n    // Open another note in column 1 (return to first column)\n    const { uri: note2Uri } = await createFile('# Note B', ['note-b.md']);\n    await vscode.window.showTextDocument(vscode.Uri.file(note2Uri.toFsPath()), {\n      viewColumn: vscode.ViewColumn.One,\n      preview: false,\n    });\n\n    // Focus should be on note in column 1\n    expect(vscode.window.activeTextEditor?.viewColumn).toBe(\n      vscode.ViewColumn.One\n    );\n\n    // Show graph again\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n    await wait(200);\n\n    // Find the graph panel - it should still be in its original column\n    graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    expect(graphPanel).toBeDefined();\n    expect(graphPanel?.group.viewColumn).toBe(originalGraphColumn);\n  });\n\n  it('should handle the issue reproduction scenario', async () => {\n    const { uri: readmeUri } = await createFile('# Readme', ['readme.md']);\n\n    // Step 1-3: Open readme.md\n    await vscode.window.showTextDocument(\n      vscode.Uri.file(readmeUri.toFsPath()),\n      {\n        viewColumn: vscode.ViewColumn.One,\n      }\n    );\n\n    // Step 4: Show graph (should appear beside the editor, not in column 1)\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n    await wait(200);\n\n    let graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    expect(graphPanel).toBeDefined();\n    const originalGraphColumn = graphPanel?.group.viewColumn;\n    // Graph should be beside (to the right of) column 1\n    expect(originalGraphColumn).toBeGreaterThan(vscode.ViewColumn.One);\n\n    // Step 5: Return focus to readme.md\n    await vscode.window.showTextDocument(\n      vscode.Uri.file(readmeUri.toFsPath()),\n      {\n        viewColumn: vscode.ViewColumn.One,\n        preserveFocus: false,\n      }\n    );\n\n    // Step 6: Open markdown preview (simulated by opening another document in the same group as graph)\n    // In real scenario, this would be the markdown preview, but for testing we'll verify\n    // that the graph stays in its column when we try to reveal it\n\n    // Step 8: Show graph again - it should NOT move\n    await vscode.commands.executeCommand('foam-vscode.show-graph');\n    await wait(200);\n\n    graphPanel = vscode.window.tabGroups.all\n      .flatMap(group => group.tabs)\n      .find(tab => tab.label === 'Foam Graph');\n\n    // Graph should still be in its original column, not replaced readme.md\n    expect(graphPanel?.group.viewColumn).toBe(originalGraphColumn);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/dataviz.test.ts",
    "content": "import { getNodeNavigationCommand } from './dataviz';\n\ndescribe('getNodeNavigationCommand', () => {\n  describe('when navigateToPreview is false', () => {\n    it('returns vscode.open for markdown files', () => {\n      expect(getNodeNavigationCommand('/path/to/note.md', false)).toBe(\n        'vscode.open'\n      );\n    });\n\n    it('returns vscode.open for non-markdown files', () => {\n      expect(getNodeNavigationCommand('/path/to/image.png', false)).toBe(\n        'vscode.open'\n      );\n    });\n  });\n\n  describe('when navigateToPreview is true', () => {\n    it('returns markdown.showPreview for markdown files', () => {\n      expect(getNodeNavigationCommand('/path/to/note.md', true)).toBe(\n        'markdown.showPreview'\n      );\n    });\n\n    it('returns vscode.open for non-markdown files', () => {\n      expect(getNodeNavigationCommand('/path/to/image.png', true)).toBe(\n        'vscode.open'\n      );\n    });\n\n    it('returns vscode.open for files with no extension', () => {\n      expect(getNodeNavigationCommand('/path/to/Makefile', true)).toBe(\n        'vscode.open'\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/index.ts",
    "content": "export { default as backlinks } from './connections';\nexport { default as dataviz } from './dataviz';\nexport { default as orphans } from './orphans';\nexport { default as placeholders } from './placeholders';\nexport { default as tags } from './tags-explorer';\nexport { default as notes } from './notes-explorer';\nexport { default as relatedNotes } from '../../ai/vscode/panels/related-notes';\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/notes-explorer.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport {\n  ResourceRangeTreeItem,\n  ResourceTreeItem,\n  createBacklinkItemsForResource as createBacklinkTreeItemsForResource,\n  expandAll,\n} from './utils/tree-view-utils';\nimport { Resource } from '../../core/model/note';\nimport { FoamGraph } from '../../core/model/graph';\nimport { ContextMemento } from '../../utils/vsc-utils';\nimport {\n  FolderTreeItem,\n  FolderTreeProvider,\n} from './utils/folder-tree-provider';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  const provider = new NotesProvider(\n    foam.workspace,\n    foam.graph,\n    context.globalState\n  );\n  provider.refresh();\n  const treeView = vscode.window.createTreeView<NotesTreeItems>(\n    'foam-vscode.notes-explorer',\n    {\n      treeDataProvider: provider,\n      showCollapseAll: true,\n      canSelectMany: true,\n    }\n  );\n  const revealTextEditorItem = async () => {\n    const target = vscode.window.activeTextEditor?.document.uri;\n    if (treeView.visible) {\n      if (target) {\n        const item = await findTreeItemByUri(provider, target);\n        // Check if the item is already selected.\n        // This check is needed because always calling reveal() will\n        // cause the tree view to take the focus from the item when\n        // browsing the notes explorer\n        if (\n          item &&\n          !treeView.selection.find(\n            i => i.resourceUri?.path === item.resourceUri.path\n          )\n        ) {\n          treeView.reveal(item);\n        }\n      }\n    }\n  };\n\n  context.subscriptions.push(\n    treeView,\n    provider,\n    foam.graph.onDidUpdate(() => {\n      provider.refresh();\n    }),\n    vscode.commands.registerCommand(\n      `foam-vscode.views.notes-explorer.expand-all`,\n      (...args) =>\n        expandAll(treeView, provider, node => node.contextValue === 'folder')\n    ),\n    vscode.window.onDidChangeActiveTextEditor(revealTextEditorItem),\n    treeView.onDidChangeVisibility(revealTextEditorItem)\n  );\n}\n\nexport function findTreeItemByUri<I, T>(\n  provider: FolderTreeProvider<I, T>,\n  target: vscode.Uri\n) {\n  const path = vscode.workspace.asRelativePath(\n    target,\n    vscode.workspace.workspaceFolders.length > 1\n  );\n  return provider.findTreeItemByPath(path.split('/'));\n}\n\nexport type NotesTreeItems =\n  | ResourceTreeItem\n  | FolderTreeItem<Resource>\n  | ResourceRangeTreeItem;\n\nexport class NotesProvider extends FolderTreeProvider<\n  NotesTreeItems,\n  Resource\n> {\n  public show: ContextMemento<'all' | 'notes-only'>;\n\n  constructor(\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    private state: vscode.Memento\n  ) {\n    super();\n    this.show = new ContextMemento<'all' | 'notes-only'>(\n      this.state,\n      `foam-vscode.views.notes-explorer.show`,\n      'all'\n    );\n\n    this.disposables.push(\n      vscode.commands.registerCommand(\n        `foam-vscode.views.notes-explorer.show:all`,\n        () => {\n          this.show.update('all');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.notes-explorer.show:notes`,\n        () => {\n          this.show.update('notes-only');\n          this.refresh();\n        }\n      )\n    );\n  }\n\n  getValues() {\n    return this.workspace.list();\n  }\n\n  getFilterFn() {\n    return this.show.get() === 'notes-only'\n      ? res => res.type !== 'image' && res.type !== 'attachment'\n      : () => true;\n  }\n\n  valueToPath(value: Resource) {\n    const path = vscode.workspace.asRelativePath(\n      value.uri.path,\n      vscode.workspace.workspaceFolders.length > 1\n    );\n    const parts = path.split('/');\n    return parts;\n  }\n\n  createValueTreeItem(\n    value: Resource,\n    parent: FolderTreeItem<Resource>\n  ): NotesTreeItems {\n    const item = new ResourceTreeItem(value, this.workspace, {\n      parent,\n      collapsibleState:\n        this.graph.getBacklinks(value.uri).length > 0\n          ? vscode.TreeItemCollapsibleState.Collapsed\n          : vscode.TreeItemCollapsibleState.None,\n    });\n    item.id = value.uri.toString();\n    item.getChildren = async () => {\n      const backlinks = await createBacklinkTreeItemsForResource(\n        this.workspace,\n        this.graph,\n        item.uri\n      );\n      backlinks.forEach(item => {\n        item.description = item.label;\n        item.label = item.resource.title;\n      });\n      return backlinks;\n    };\n    item.description =\n      value.uri.getName().toLocaleLowerCase() ===\n      value.title.toLocaleLowerCase()\n        ? undefined\n        : value.uri.getBasename();\n    return item;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/orphans.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { createMatcherAndDataStore } from '../../services/editor';\nimport {\n  getAttachmentsExtensions,\n  getIncludeFilesSetting,\n} from '../../settings';\nimport {\n  GroupedResourcesConfig,\n  GroupedResourcesTreeDataProvider,\n} from './utils/grouped-resources-tree-data-provider';\nimport { ResourceTreeItem, UriTreeItem } from './utils/tree-view-utils';\nimport { IMatcher } from '../../core/services/datastore';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FoamGraph } from '../../core/model/graph';\nimport { URI } from '../../core/model/uri';\nimport { imageExtensions } from '../../core/services/attachment-provider';\n\nconst EXCLUDE_TYPES = ['image', 'attachment'];\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  const { matcher } = await createMatcherAndDataStore(\n    getIncludeFilesSetting().map(g => g.toString()),\n    getOrphansConfig().exclude\n  );\n  const provider = new OrphanTreeView(\n    context.globalState,\n    foam.workspace,\n    foam.graph,\n    matcher\n  );\n\n  const treeView = vscode.window.createTreeView('foam-vscode.orphans', {\n    treeDataProvider: provider,\n    showCollapseAll: true,\n  });\n  provider.refresh();\n  const baseTitle = treeView.title;\n  treeView.title = baseTitle + ` (${provider.nValues})`;\n\n  context.subscriptions.push(\n    vscode.window.registerTreeDataProvider('foam-vscode.orphans', provider),\n    provider,\n    treeView,\n    foam.graph.onDidUpdate(() => {\n      provider.refresh();\n      treeView.title = baseTitle + ` (${provider.nValues})`;\n    })\n  );\n}\n\n/** Retrieve the orphans configuration */\nexport function getOrphansConfig(): GroupedResourcesConfig {\n  const orphansConfig = vscode.workspace.getConfiguration('foam.orphans');\n  const exclude: string[] = orphansConfig.get('exclude');\n  return { exclude };\n}\n\nexport class OrphanTreeView extends GroupedResourcesTreeDataProvider {\n  constructor(\n    state: vscode.Memento,\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    matcher: IMatcher\n  ) {\n    super('orphans', state, matcher);\n  }\n\n  createValueTreeItem = uri => {\n    return uri.isPlaceholder()\n      ? new UriTreeItem(uri)\n      : new ResourceTreeItem(this.workspace.find(uri), this.workspace);\n  };\n\n  getUris = () =>\n    this.graph\n      .getAllNodes()\n      .filter(\n        uri =>\n          !EXCLUDE_TYPES.includes(this.workspace.find(uri)?.type) &&\n          this.graph.getBacklinks(uri).length === 0 &&\n          this.graph.getLinks(uri).filter(c => !isAttachment(c.target))\n            .length === 0\n      );\n}\n\nfunction isAttachment(uri: URI) {\n  const ext = [...getAttachmentsExtensions(), ...imageExtensions];\n  return ext.includes(uri.getExtension());\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/placeholders.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { createMatcherAndDataStore } from '../../services/editor';\nimport {\n  GroupedResourcesConfig,\n  GroupedResourcesTreeDataProvider,\n} from './utils/grouped-resources-tree-data-provider';\nimport {\n  UriTreeItem,\n  createBacklinkItemsForResource,\n  expandAll,\n  groupRangesByResource,\n} from './utils/tree-view-utils';\nimport { IMatcher } from '../../core/services/datastore';\nimport { ContextMemento, fromVsCodeUri } from '../../utils/vsc-utils';\nimport { FoamGraph } from '../../core/model/graph';\nimport { URI } from '../../core/model/uri';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FolderTreeItem } from './utils/folder-tree-provider';\nimport { getIncludeFilesSetting } from '../../settings';\n\n/** Retrieve the placeholders configuration */\nexport function getPlaceholdersConfig(): GroupedResourcesConfig {\n  const placeholderCfg = vscode.workspace.getConfiguration('foam.placeholders');\n  const exclude: string[] = placeholderCfg.get('exclude');\n  return { exclude };\n}\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  const { matcher } = await createMatcherAndDataStore(\n    getIncludeFilesSetting().map(g => g.toString()),\n    getPlaceholdersConfig().exclude\n  );\n  const provider = new PlaceholderTreeView(\n    context.globalState,\n    foam.workspace,\n    foam.graph,\n    matcher\n  );\n\n  const treeView = vscode.window.createTreeView('foam-vscode.placeholders', {\n    treeDataProvider: provider,\n    showCollapseAll: true,\n  });\n  provider.refresh();\n  const baseTitle = treeView.title;\n  treeView.title = baseTitle + ` (${provider.nValues})`;\n\n  context.subscriptions.push(\n    treeView,\n    provider,\n    foam.graph.onDidUpdate(() => {\n      provider.refresh();\n    }),\n    provider.onDidChangeTreeData(() => {\n      treeView.title = baseTitle + ` (${provider.nValues})`;\n    }),\n    vscode.commands.registerCommand(\n      `foam-vscode.views.placeholders.expand-all`,\n      () =>\n        expandAll(\n          treeView,\n          provider,\n          node =>\n            node.contextValue === 'placeholder' ||\n            node.contextValue === 'folder'\n        )\n    ),\n    vscode.window.onDidChangeActiveTextEditor(() => {\n      if (provider.show.get() === 'for-current-file') {\n        provider.refresh();\n      }\n    })\n  );\n}\n\nexport class PlaceholderTreeView extends GroupedResourcesTreeDataProvider {\n  public show = new ContextMemento<'all' | 'for-current-file'>(\n    this.state,\n    `foam-vscode.views.${this.providerId}.show`,\n    'all'\n  );\n\n  public constructor(\n    state: vscode.Memento,\n    private workspace: FoamWorkspace,\n    private graph: FoamGraph,\n    matcher: IMatcher\n  ) {\n    super('placeholders', state, matcher);\n    this.disposables.push(\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.show:all`,\n        () => {\n          this.show.update('all');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.show:for-current-file`,\n        () => {\n          this.show.update('for-current-file');\n          this.refresh();\n        }\n      )\n    );\n  }\n\n  createValueTreeItem(uri: URI, parent: FolderTreeItem<URI>): UriTreeItem {\n    const item = new UriTreeItem(uri, {\n      parent,\n      collapsibleState: vscode.TreeItemCollapsibleState.Collapsed,\n    });\n    item.contextValue = 'placeholder';\n    item.id = uri.toString();\n    item.getChildren = async () => {\n      return groupRangesByResource(\n        this.workspace,\n        await createBacklinkItemsForResource(\n          this.workspace,\n          this.graph,\n          uri,\n          'link'\n        )\n      );\n    };\n    return item;\n  }\n\n  getUris(): URI[] {\n    if (this.show.get() === 'for-current-file') {\n      const currentFile = vscode.window.activeTextEditor?.document.uri;\n      return currentFile\n        ? this.graph\n            .getLinks(fromVsCodeUri(currentFile))\n            .map(link => link.target)\n            .filter(uri => uri.isPlaceholder())\n        : [];\n    }\n    return this.graph.getAllNodes().filter(uri => uri.isPlaceholder());\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/tags-explorer.spec.ts",
    "content": "/* @unit-ready */\nimport { createTestNote } from '../../test/test-utils';\nimport { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';\nimport { TagItem, TagsProvider } from './tags-explorer';\nimport { FoamTags } from '../../core/model/tags';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { ResourceTreeItem } from './utils/tree-view-utils';\n\ndescribe('Tags tree panel', () => {\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  it('provides a tag from a set of notes', async () => {\n    const noteA = createTestNote({\n      tags: ['test'],\n      uri: './note-a.md',\n    });\n    const workspace = new FoamWorkspace().set(noteA);\n    const foamTags = FoamTags.fromWorkspace(workspace);\n    const provider = new TagsProvider(foamTags, workspace, false);\n    provider.refresh();\n\n    const treeItems = (await provider.getChildren()) as TagItem[];\n\n    expect(treeItems).toHaveLength(1);\n    expect(treeItems[0].label).toEqual('test');\n    expect(treeItems[0].tag).toEqual('test');\n    expect(treeItems[0].nResourcesInSubtree).toEqual(1);\n  });\n\n  it('handles a simple parent and child tag', async () => {\n    const noteA = createTestNote({\n      tags: ['parent/child'],\n      uri: './note-a.md',\n    });\n    const workspace = new FoamWorkspace().set(noteA);\n    const foamTags = FoamTags.fromWorkspace(workspace);\n    const provider = new TagsProvider(foamTags, workspace, false);\n    provider.refresh();\n\n    const parentTreeItems = (await provider.getChildren()) as TagItem[];\n    const parentTagItem = parentTreeItems.pop();\n    expect(parentTagItem.label).toEqual('parent');\n\n    const childTreeItems = (await provider.getChildren(\n      parentTagItem\n    )) as TagItem[];\n\n    childTreeItems.forEach(child => {\n      if (child instanceof TagItem) {\n        // eslint-disable-next-line jest/no-conditional-expect\n        expect(child.label).toEqual('child');\n      }\n    });\n  });\n\n  it('handles a single parent and multiple child tag', async () => {\n    const noteA = createTestNote({\n      tags: ['parent/child'],\n      uri: './note-a.md',\n    });\n    const noteB = createTestNote({\n      tags: ['parent/subchild'],\n      uri: './note-b.md',\n    });\n    const workspace = new FoamWorkspace().set(noteA).set(noteB);\n    const foamTags = FoamTags.fromWorkspace(workspace);\n    const provider = new TagsProvider(foamTags, workspace, false);\n    provider.refresh();\n\n    const parentTreeItems = (await provider.getChildren()) as TagItem[];\n    const parentTagItem = parentTreeItems.filter(\n      item => item instanceof TagItem\n    )[0];\n\n    expect(parentTagItem.label).toEqual('parent');\n    expect(parentTreeItems).toHaveLength(1);\n\n    const childTreeItems = (await provider.getChildren(\n      parentTagItem\n    )) as TagItem[];\n\n    childTreeItems.forEach(child => {\n      if (child instanceof TagItem) {\n        // eslint-disable-next-line jest/no-conditional-expect\n        expect(['child', 'subchild']).toContain(child.label);\n        // eslint-disable-next-line jest/no-conditional-expect\n        expect(child.label).not.toEqual('parent');\n      }\n    });\n    expect(childTreeItems).toHaveLength(2);\n  });\n\n  it('handles a parent and child tag in the same note', async () => {\n    const noteC = createTestNote({\n      tags: ['main', 'main/subtopic'],\n      title: 'Test note',\n      uri: './note-c.md',\n    });\n    const workspace = new FoamWorkspace().set(noteC);\n    const foamTags = FoamTags.fromWorkspace(workspace);\n    const provider = new TagsProvider(foamTags, workspace, false);\n\n    provider.refresh();\n\n    const parentTreeItems = (await provider.getChildren()) as TagItem[];\n    const parentTagItem = parentTreeItems.filter(\n      item => item instanceof TagItem\n    )[0];\n\n    expect(parentTagItem.label).toEqual('main');\n\n    const childTreeItems = (await provider.getChildren(\n      parentTagItem\n    )) as TagItem[];\n\n    childTreeItems\n      .filter(item => item instanceof ResourceTreeItem)\n      .forEach(item => {\n        expect(item.label).toEqual('Test note');\n      });\n\n    childTreeItems\n      .filter(item => item instanceof TagItem)\n      .forEach(item => {\n        expect(['main/subtopic']).toContain(item.tag);\n        expect(item.label).toEqual('subtopic');\n      });\n\n    expect(childTreeItems).toHaveLength(2);\n  });\n\n  it('handles a tag with multiple levels of hierarchy - #1134', async () => {\n    const noteA = createTestNote({\n      tags: ['parent/child/second'],\n      uri: './note-a.md',\n    });\n    const workspace = new FoamWorkspace().set(noteA);\n    const foamTags = FoamTags.fromWorkspace(workspace);\n    const provider = new TagsProvider(foamTags, workspace, false);\n\n    provider.refresh();\n\n    const parentTreeItems = (await provider.getChildren()) as TagItem[];\n    const parentTagItem = parentTreeItems.pop();\n    expect(parentTagItem.label).toEqual('parent');\n\n    const childTreeItems = (await provider.getChildren(\n      parentTagItem\n    )) as TagItem[];\n\n    expect(childTreeItems).toHaveLength(1);\n    expect(childTreeItems[0].label).toEqual('child');\n\n    const grandchildTreeItems = (await provider.getChildren(\n      childTreeItems[0]\n    )) as TagItem[];\n\n    expect(grandchildTreeItems).toHaveLength(1);\n    expect(grandchildTreeItems[0].label).toEqual('second');\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/tags-explorer.ts",
    "content": "import { URI } from '../../core/model/uri';\nimport * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FoamTags } from '../../core/model/tags';\nimport {\n  ResourceRangeTreeItem,\n  ResourceTreeItem,\n  expandAll,\n  groupRangesByResource,\n} from './utils/tree-view-utils';\nimport {\n  Folder,\n  FolderTreeItem,\n  FolderTreeProvider,\n  walk,\n} from './utils/folder-tree-provider';\nimport {\n  ContextMemento,\n  MapBasedMemento,\n  fromVsCodeUri,\n} from '../../utils/vsc-utils';\n\nconst TAG_SEPARATOR = '/';\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  const provider = new TagsProvider(foam.tags, foam.workspace);\n  const treeView = vscode.window.createTreeView('foam-vscode.tags-explorer', {\n    treeDataProvider: provider,\n    showCollapseAll: true,\n  });\n  provider.refresh();\n  const baseTitle = treeView.title;\n  treeView.title = baseTitle + ` (${foam.tags.tags.size})`;\n  context.subscriptions.push(\n    treeView,\n    foam.tags.onDidUpdate(() => {\n      provider.refresh();\n      treeView.title = baseTitle + ` (${foam.tags.tags.size})`;\n    }),\n    vscode.window.onDidChangeActiveTextEditor(() => {\n      if (provider.show.get() === 'for-current-file') {\n        provider.refresh();\n      }\n    }),\n    vscode.commands.registerCommand(\n      `foam-vscode.views.${provider.providerId}.expand-all`,\n      () =>\n        expandAll(\n          treeView,\n          provider,\n          node => node.contextValue === 'tag' || node.contextValue === 'folder'\n        )\n    ),\n    vscode.commands.registerCommand(\n      `foam-vscode.views.${provider.providerId}.focus`,\n      async (tag?: string, source?: object) => {\n        if (tag == null) {\n          tag = await vscode.window.showQuickPick(\n            Array.from(foam.tags.tags.keys()),\n            {\n              title: 'Select a tag to focus',\n            }\n          );\n        }\n        if (tag == null) {\n          return;\n        }\n        const tagItem = (await provider.findTreeItemByPath(\n          provider.valueToPath(tag)\n        )) as TagItem;\n        if (tagItem == null) {\n          return;\n        }\n        await treeView.reveal(tagItem, {\n          select: true,\n          focus: true,\n          expand: true,\n        });\n        const children = await provider.getChildren(tagItem);\n        const sourceUri = source ? new URI(source) : undefined;\n        const resourceItem = sourceUri\n          ? children.find(\n              t =>\n                t instanceof ResourceTreeItem &&\n                sourceUri.isEqual(t.resource?.uri)\n            )\n          : undefined;\n        // doing it as a two reveal process as revealing just the resource\n        // was only working when the tag item was already expanded\n        if (resourceItem) {\n          treeView.reveal(resourceItem, {\n            select: true,\n            focus: true,\n            expand: false,\n          });\n        }\n      }\n    )\n  );\n}\n\nexport class TagsProvider extends FolderTreeProvider<TagTreeItem, string> {\n  public providerId = 'tags-explorer';\n  public show = new ContextMemento<'all' | 'for-current-file'>(\n    new MapBasedMemento(),\n    `foam-vscode.views.${this.providerId}.show`,\n    'all'\n  );\n  public groupBy = new ContextMemento<'off' | 'folder'>(\n    new MapBasedMemento(),\n    `foam-vscode.views.${this.providerId}.group-by`,\n    'folder'\n  );\n\n  private tags: {\n    tag: string;\n    notes: URI[];\n  }[];\n\n  constructor(\n    private foamTags: FoamTags,\n    private workspace: FoamWorkspace,\n    registerCommands: boolean = true\n  ) {\n    super();\n    if (!registerCommands) {\n      return;\n    }\n    this.disposables.push(\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.show:all`,\n        () => {\n          this.show.update('all');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.show:for-current-file`,\n        () => {\n          this.show.update('for-current-file');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.group-by:folder`,\n        () => {\n          this.groupBy.update('folder');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.group-by:off`,\n        () => {\n          this.groupBy.update('off');\n          this.refresh();\n        }\n      )\n    );\n  }\n\n  refresh(): void {\n    this.tags = [...this.foamTags.tags]\n      .map(([tag, resources]) => ({ tag, notes: resources.map(r => r.uri) }))\n      .sort((a, b) => a.tag.localeCompare(b.tag));\n    super.refresh();\n  }\n\n  getValues(): string[] {\n    if (this.show.get() === 'for-current-file') {\n      const uriInEditor = vscode.window.activeTextEditor?.document.uri;\n      const currentResource = this.workspace.find(fromVsCodeUri(uriInEditor));\n      return currentResource?.tags.map(t => t.label) ?? [];\n    }\n    return Array.from(this.tags.values()).map(tag => tag.tag);\n  }\n\n  valueToPath(value: string) {\n    return this.groupBy.get() === 'off' ? [value] : value.split(TAG_SEPARATOR);\n  }\n\n  private countResourcesInSubtree(node: Folder<string>) {\n    const uniqueUris = new Set<string>();\n    walk(node, tag => {\n      const tagLocations = this.foamTags.tags.get(tag) ?? [];\n      tagLocations.forEach(location => uniqueUris.add(location.uri.toString()));\n      return 0; // Return value not used when collecting URIs\n    });\n    return uniqueUris.size;\n  }\n\n  createFolderTreeItem(\n    node: Folder<string>,\n    name: string,\n    parent: FolderTreeItem<string>\n  ): FolderTreeItem<string> {\n    const nChildren = this.countResourcesInSubtree(node);\n    return new TagItem(node, nChildren, [], parent);\n  }\n\n  createValueTreeItem(\n    value: string,\n    parent: FolderTreeItem<string>,\n    node: Folder<string>\n  ): TagItem {\n    const nChildren = this.countResourcesInSubtree(node);\n    const tagLocations = this.foamTags.tags.get(value) ?? [];\n    const resourceUris = tagLocations.map(location => location.uri);\n    return new TagItem(node, nChildren, resourceUris, parent);\n  }\n\n  async getChildren(element?: TagItem): Promise<TagTreeItem[]> {\n    if ((element as any)?.getChildren) {\n      const children = await (element as any).getChildren();\n      return children;\n    }\n\n    // Subtags are managed by the FolderTreeProvider\n    const subtags = await super.getChildren(element);\n\n    // Compute the resources children\n    const resourceTags: ResourceRangeTreeItem[] = [];\n    if (element) {\n      const tagLocations = this.foamTags.tags.get(element.tag) ?? [];\n      const resourceTagPromises = tagLocations.map(async tagLocation => {\n        const note = this.workspace.get(tagLocation.uri);\n        return ResourceRangeTreeItem.createStandardItem(\n          this.workspace,\n          note,\n          tagLocation.range,\n          'tag'\n        );\n      });\n      resourceTags.push(...(await Promise.all(resourceTagPromises)));\n    }\n    const resources = (\n      await groupRangesByResource(this.workspace, resourceTags)\n    ).map(item => {\n      item.id = element.tag + ' / ' + item.uri.toString();\n      return item;\n    });\n\n    return [...subtags, ...resources];\n  }\n}\n\ntype TagTreeItem = TagItem | ResourceTreeItem | ResourceRangeTreeItem;\n\nexport class TagItem extends FolderTreeItem<string> {\n  public readonly tag: string;\n\n  constructor(\n    public readonly node: Folder<string>,\n    public readonly nResourcesInSubtree: number,\n    public readonly notes: URI[],\n    public readonly parentElement?: FolderTreeItem<string>\n  ) {\n    super(node, node.path.slice(-1)[0], parentElement);\n    this.tag = node.path.join(TAG_SEPARATOR);\n    this.id = this.tag;\n    this.description = `${nResourcesInSubtree} reference${\n      nResourcesInSubtree !== 1 ? 's' : ''\n    }`;\n  }\n\n  iconPath = new vscode.ThemeIcon('symbol-number');\n  contextValue = 'tag';\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/utils/base-tree-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { IDisposable } from '../../../core/common/lifecycle';\n\n/**\n * This class is a wrapper around vscode.TreeDataProvider that adds a few\n * features:\n * - It adds a `refresh()` method that can be called to refresh the tree view\n * - It adds a `resolveTreeItem()` method that can be used to resolve the\n *   tree item asynchronously. This is useful when the tree item needs to\n *   fetch data from the file system or from the network.\n * - It adds a `dispose()` method that can be used to dispose of any resources\n *   that the tree provider might be holding on to.\n */\nexport abstract class BaseTreeProvider<T>\n  implements vscode.TreeDataProvider<T>, IDisposable\n{\n  protected disposables: vscode.Disposable[] = [];\n\n  // prettier-ignore\n  private _onDidChangeTreeData: vscode.EventEmitter<T | undefined | void> = new vscode.EventEmitter<T | undefined | void>();\n  // prettier-ignore\n  readonly onDidChangeTreeData: vscode.Event<T | undefined | void> = this._onDidChangeTreeData.event;\n\n  abstract getChildren(element?: T): vscode.ProviderResult<T[]>;\n\n  getTreeItem(element: T) {\n    return element;\n  }\n\n  async resolveTreeItem(item: T): Promise<T> {\n    if ((item as any)?.resolveTreeItem) {\n      return (item as any).resolveTreeItem();\n    }\n    return Promise.resolve(item);\n  }\n\n  refresh(): void {\n    this._onDidChangeTreeData.fire();\n  }\n\n  dispose(): void {\n    this.disposables.forEach(d => d.dispose());\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/utils/folder-tree-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { BaseTreeProvider } from './base-tree-provider';\nimport { BaseTreeItem, ResourceTreeItem } from './tree-view-utils';\n\n/**\n * A folder is a map of basenames to either folders or values (e.g. resources).\n */\nexport interface Folder<T> {\n  children: {\n    [basename: string]: Folder<T>;\n  };\n  value?: T;\n  path: string[];\n}\n\n/**\n * A TreeItem that represents a folder.\n */\nexport class FolderTreeItem<T> extends vscode.TreeItem {\n  collapsibleState = vscode.TreeItemCollapsibleState.Collapsed;\n  contextValue = 'folder';\n\n  constructor(\n    public node: Folder<T>,\n    public name: string,\n    public parentElement?: FolderTreeItem<T>\n  ) {\n    super(name, vscode.TreeItemCollapsibleState.Collapsed);\n  }\n}\n\n/**\n * An abstract class that can be used to create a tree view from a Folder object.\n * Its abstract methods must be implemented by the subclass to define the type of\n * the values in the folder, and how to filter them.\n */\nexport abstract class FolderTreeProvider<I, T> extends BaseTreeProvider<I> {\n  private root: Folder<T>;\n  public nValues = 0;\n\n  refresh(): void {\n    const values = this.getValues();\n    this.nValues = values.length;\n    this.createTree(values, this.getFilterFn());\n    super.refresh();\n  }\n\n  getParent(element: I | FolderTreeItem<T>): vscode.ProviderResult<I> {\n    if (element instanceof ResourceTreeItem) {\n      return Promise.resolve(element.parent as I);\n    }\n    if (element instanceof FolderTreeItem) {\n      return Promise.resolve(element.parentElement as any);\n    }\n  }\n\n  createFolderTreeItem(\n    node: Folder<T>,\n    name: string,\n    parent: FolderTreeItem<T>\n  ) {\n    return new FolderTreeItem<T>(node, name, parent);\n  }\n\n  async getChildren(item?: I): Promise<I[]> {\n    if (item instanceof BaseTreeItem) {\n      return item.getChildren() as Promise<I[]>;\n    }\n\n    const parent: Folder<T> = (item as any)?.node ?? this.root;\n\n    const children: vscode.TreeItem[] = Object.keys(parent?.children ?? []).map(\n      name => {\n        const node = parent.children[name];\n        if (node.value != null) {\n          return this.createValueTreeItem(node.value, undefined, node);\n        } else {\n          return this.createFolderTreeItem(\n            node,\n            name,\n            item as unknown as FolderTreeItem<T>\n          );\n        }\n      }\n    );\n\n    return children.sort((a, b) => sortFolderTreeItems(a, b)) as any;\n  }\n\n  createTree(values: T[], filterFn: (value: T) => boolean): Folder<T> {\n    const root: Folder<T> = {\n      children: {},\n      path: [],\n    };\n\n    for (const r of values) {\n      const parts = this.valueToPath(r);\n      let currentNode: Folder<T> = root;\n\n      parts.forEach((part, index) => {\n        if (!currentNode.children[part]) {\n          if (index < parts.length - 1) {\n            currentNode.children[part] = {\n              children: {},\n              path: parts.slice(0, index + 1),\n            };\n          } else if (filterFn(r)) {\n            currentNode.children[part] = {\n              children: {},\n              path: parts.slice(0, index + 1),\n              value: r,\n            };\n          }\n        }\n        currentNode = currentNode.children[part];\n      });\n    }\n\n    this.root = root;\n    return root;\n  }\n\n  getTreeItemsHierarchy(path: string[]): vscode.TreeItem[] {\n    const treeItemsHierarchy: vscode.TreeItem[] = [];\n    let currentNode: Folder<T> = this.root;\n\n    for (const part of path) {\n      if (currentNode.children[part] !== undefined) {\n        currentNode = currentNode.children[part] as Folder<T>;\n        if (currentNode.value) {\n          treeItemsHierarchy.push(\n            this.createValueTreeItem(\n              currentNode.value,\n              treeItemsHierarchy[\n                treeItemsHierarchy.length - 1\n              ] as FolderTreeItem<T>,\n              currentNode\n            )\n          );\n        } else {\n          treeItemsHierarchy.push(\n            new FolderTreeItem(\n              currentNode,\n              part,\n              treeItemsHierarchy[\n                treeItemsHierarchy.length - 1\n              ] as FolderTreeItem<T>\n            )\n          );\n        }\n      } else {\n        // If a part is not found in the tree structure, the given URI is not valid.\n        return [];\n      }\n    }\n\n    return treeItemsHierarchy;\n  }\n\n  findTreeItemByPath(path: string[]): Promise<I> {\n    const hierarchy = this.getTreeItemsHierarchy(path);\n    return hierarchy.length > 0\n      ? Promise.resolve(hierarchy.pop())\n      : Promise.resolve(null);\n  }\n\n  /**\n   * Returns a function that can be used to filter the values.\n   * The difference between using this function vs not including the values\n   * is that in this case, the tree will be created with all the folders\n   * and subfolders, but the values will only be displayed if they pass\n   * the filter.\n   * By default it doesn't filter anything.\n   */\n  getFilterFn(): (value: T) => boolean {\n    return () => true;\n  }\n\n  /**\n   * Converts a value to a path of strings that can be used to create a tree.\n   */\n  abstract valueToPath(value: T);\n\n  /**\n   * Returns all the values that should be displayed in the tree.\n   */\n  abstract getValues(): T[];\n\n  /**\n   * Creates a tree item for the given value.\n   */\n  abstract createValueTreeItem(\n    value: T,\n    parent: FolderTreeItem<T>,\n    node: Folder<T>\n  ): I;\n}\n\n/**\n * walks the node and performs an action on each value\n * @returns\n */\nexport function walk<T, R>(node: Folder<T>, fn: (value: T) => R): R[] {\n  const results: R[] = [];\n\n  function traverse(node: Folder<T>) {\n    if (node.value) {\n      results.push(fn(node.value));\n    }\n\n    Object.values(node.children).forEach(child => {\n      traverse(child);\n    });\n  }\n\n  traverse(node);\n\n  return results;\n}\n\nfunction sortFolderTreeItems(a: vscode.TreeItem, b: vscode.TreeItem): number {\n  // Both a and b are FolderTreeItem instances\n  if (a instanceof FolderTreeItem && b instanceof FolderTreeItem) {\n    return a.label.toString().localeCompare(b.label.toString());\n  }\n\n  // Only a is a FolderTreeItem instance\n  if (a instanceof FolderTreeItem) {\n    return -1;\n  }\n\n  // Only b is a FolderTreeItem instance\n  if (b instanceof FolderTreeItem) {\n    return 1;\n  }\n\n  return a.label.toString().localeCompare(b.label.toString());\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/utils/grouped-resources-tree-data-provider.spec.ts",
    "content": "import { FoamWorkspace } from '../../../core/model/workspace';\nimport {\n  AlwaysIncludeMatcher,\n  IMatcher,\n  SubstringExcludeMatcher,\n} from '../../../core/services/datastore';\nimport { createTestNote } from '../../../test/test-utils';\nimport { ResourceTreeItem, UriTreeItem } from './tree-view-utils';\nimport { randomString } from '../../../test/test-utils';\nimport { MapBasedMemento } from '../../../utils/vsc-utils';\nimport { URI } from '../../../core/model/uri';\nimport { TreeItem } from 'vscode';\nimport { GroupedResourcesTreeDataProvider } from './grouped-resources-tree-data-provider';\n\nconst testMatcher = new SubstringExcludeMatcher('path-exclude');\n\nclass TestProvider extends GroupedResourcesTreeDataProvider {\n  constructor(\n    matcher: IMatcher,\n    private list: () => URI[],\n    private create: (uri: URI) => TreeItem\n  ) {\n    super(randomString(), new MapBasedMemento(), matcher);\n  }\n  getUris(): URI[] {\n    return this.list();\n  }\n  createValueTreeItem(value: URI) {\n    return this.create(value) as any;\n  }\n}\n\ndescribe('TestProvider', () => {\n  const note1 = createTestNote({ uri: '/path/ABC.md', title: 'ABC' });\n  const note2 = createTestNote({\n    uri: '/path-bis/XYZ.md',\n    title: 'XYZ',\n  });\n  const note3 = createTestNote({\n    uri: '/path-bis/ABCDEFG.md',\n    title: 'ABCDEFG',\n  });\n  const excludedNote = createTestNote({\n    uri: '/path-exclude/HIJ.m',\n    title: 'HIJ',\n  });\n\n  const workspace = new FoamWorkspace()\n    .set(note1)\n    .set(note2)\n    .set(note3)\n    .set(excludedNote);\n\n  it('should return the grouped resources as a folder tree', async () => {\n    const provider = new TestProvider(\n      testMatcher,\n      () => workspace.list().map(r => r.uri),\n      uri => new UriTreeItem(uri)\n    );\n    provider.groupBy.update('folder');\n    provider.refresh();\n    const result = await provider.getChildren();\n    expect(result).toMatchObject([\n      {\n        collapsibleState: 1,\n        label: '/path',\n        description: '(1)',\n      },\n      {\n        collapsibleState: 1,\n        label: '/path-bis',\n        description: '(2)',\n      },\n    ]);\n  });\n  it('should return the grouped resources in a directory', async () => {\n    const provider = new TestProvider(\n      testMatcher,\n      () => workspace.list().map(r => r.uri),\n      uri => new ResourceTreeItem(workspace.get(uri), workspace)\n    );\n    provider.groupBy.update('folder');\n    provider.refresh();\n    const paths = await provider.getChildren();\n    const directory = paths[0];\n    expect(directory).toMatchObject({\n      label: '/path',\n    });\n    const result = await provider.getChildren(directory);\n    expect(result).toMatchObject([\n      {\n        collapsibleState: 0,\n        label: 'ABC',\n        description: '/path/ABC.md',\n        command: { command: 'vscode.open' },\n      },\n    ]);\n  });\n  it('should return the flattened resources', async () => {\n    const provider = new TestProvider(\n      testMatcher,\n      () => workspace.list().map(r => r.uri),\n      uri => new ResourceTreeItem(workspace.get(uri), workspace)\n    );\n    provider.groupBy.update('off');\n    provider.refresh();\n    const result = await provider.getChildren();\n    expect(result).toMatchObject([\n      {\n        collapsibleState: 0,\n        label: note1.title,\n        description: '/path/ABC.md',\n        command: { command: 'vscode.open' },\n      },\n      {\n        collapsibleState: 0,\n        label: note3.title,\n        description: '/path-bis/ABCDEFG.md',\n        command: { command: 'vscode.open' },\n      },\n      {\n        collapsibleState: 0,\n        label: note2.title,\n        description: '/path-bis/XYZ.md',\n        command: { command: 'vscode.open' },\n      },\n    ]);\n  });\n  it('should return the grouped resources without exclusion', async () => {\n    const provider = new TestProvider(\n      new AlwaysIncludeMatcher(),\n      () => workspace.list().map(r => r.uri),\n      uri => new UriTreeItem(uri)\n    );\n    provider.groupBy.update('folder');\n    provider.refresh();\n    const result = await provider.getChildren();\n    expect(result.length).toEqual(3);\n    expect(result).toMatchObject([\n      {\n        collapsibleState: 1,\n        label: '/path',\n        description: '(1)',\n      },\n      {\n        collapsibleState: 1,\n        label: '/path-bis',\n        description: '(2)',\n      },\n      {\n        collapsibleState: 1,\n        label: '/path-exclude',\n        description: '(1)',\n      },\n    ]);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/utils/grouped-resources-tree-data-provider.ts",
    "content": "import * as path from 'path';\nimport * as vscode from 'vscode';\nimport { URI } from '../../../core/model/uri';\nimport { IMatcher } from '../../../core/services/datastore';\nimport { UriTreeItem } from './tree-view-utils';\nimport { ContextMemento } from '../../../utils/vsc-utils';\nimport {\n  FolderTreeItem,\n  FolderTreeProvider,\n  Folder,\n} from './folder-tree-provider';\n\nexport interface GroupedResourcesConfig {\n  exclude: string[];\n}\n\ntype GroupedResourceTreeItem = UriTreeItem | FolderTreeItem<URI>;\n\n/**\n * Provides the ability to expose a TreeDataExplorerView in VSCode. This class will\n * iterate over each Resource in the FoamWorkspace, call the provided filter predicate, and\n * display the Resources.\n *\n * **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:\n * ```\n * foam-vscode.views.${providerId}.group-by-folder\n * foam-vscode.views.${providerId}.group-off\n * ```\n * Where `providerId` is the same string provided to the constructor.\n * @export\n * @class GroupedResourcesTreeDataProvider\n * @implements {vscode.TreeDataProvider<GroupedResourceTreeItem>}\n */\nexport abstract class GroupedResourcesTreeDataProvider extends FolderTreeProvider<\n  GroupedResourceTreeItem,\n  URI\n> {\n  public groupBy: ContextMemento<'off' | 'folder'>;\n\n  /**\n   * Creates an instance of GroupedResourcesTreeDataProvider.\n   * **NOTE**: In order for this provider to correctly function, you must define the following command in the package.json file:\n   * ```\n   * foam-vscode.views.${this.providerId}.group-by:folder\n   * foam-vscode.views.${this.providerId}.group-by:off\n   * ```\n   * Where `providerId` is the same string provided to this constructor.\n   *\n   * @param {string} providerId A **unique** providerId, this will be used to generate necessary commands within the provider.\n   * @param {vscode.Memento} state The state to use for persisting the panel settings.\n   * @param {IMatcher} matcher The matcher to use for filtering the uris.\n   * @memberof GroupedResourcesTreeDataProvider\n   */\n  constructor(\n    protected providerId: string,\n    protected state: vscode.Memento,\n    private matcher: IMatcher\n  ) {\n    super();\n    this.groupBy = new ContextMemento<'off' | 'folder'>(\n      this.state,\n      `foam-vscode.views.${this.providerId}.group-by`,\n      'folder'\n    );\n\n    this.disposables.push(\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.group-by:folder`,\n        () => {\n          this.groupBy.update('folder');\n          this.refresh();\n        }\n      ),\n      vscode.commands.registerCommand(\n        `foam-vscode.views.${this.providerId}.group-by:off`,\n        () => {\n          this.groupBy.update('off');\n          this.refresh();\n        }\n      )\n    );\n  }\n\n  valueToPath(value: URI) {\n    const p = vscode.workspace.asRelativePath(\n      value.path,\n      vscode.workspace.workspaceFolders.length > 1\n    );\n    if (this.groupBy.get() === 'folder') {\n      const { dir, base } = path.parse(p);\n      return [dir, base];\n    }\n    return [p];\n  }\n\n  getValues(): URI[] {\n    const uris = this.getUris();\n    return uris.filter(uri => this.matcher.isMatch(uri));\n  }\n\n  createFolderTreeItem(\n    node: Folder<URI>,\n    name: string,\n    parent: FolderTreeItem<URI>\n  ) {\n    const item = super.createFolderTreeItem(node, name, parent);\n    item.label = item.label || '(Not Created)';\n    item.description = `(${Object.keys(node.children).length})`;\n    return item;\n  }\n\n  /**\n   * Return the URIs before the filtering by the matcher is applied\n   */\n  abstract getUris(): URI[];\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/panels/utils/tree-view-utils.ts",
    "content": "import * as vscode from 'vscode';\nimport { groupBy } from 'lodash';\nimport { Resource } from '../../../core/model/note';\nimport { toVsCodeUri } from '../../../utils/vsc-utils';\nimport { Range } from '../../../core/model/range';\nimport { URI } from '../../../core/model/uri';\nimport { FoamWorkspace } from '../../../core/model/workspace';\nimport { isSome } from '../../../core/utils';\nimport { getBlockFor } from '../../../core/services/markdown-parser';\nimport { Connection, FoamGraph } from '../../../core/model/graph';\nimport { Logger } from '../../../core/utils/log';\nimport { getNoteTooltip } from '../../../services/editor';\n\nexport class BaseTreeItem extends vscode.TreeItem {\n  resolveTreeItem(): Promise<vscode.TreeItem> {\n    return Promise.resolve(this);\n  }\n\n  getChildren(): Promise<vscode.TreeItem[]> {\n    return Promise.resolve([]);\n  }\n}\n\nexport class UriTreeItem extends BaseTreeItem {\n  public parent?: vscode.TreeItem;\n\n  constructor(\n    public readonly uri: URI,\n    options: {\n      collapsibleState?: vscode.TreeItemCollapsibleState;\n      title?: string;\n      parent?: vscode.TreeItem;\n    } = {}\n  ) {\n    super(options?.title ?? uri.getName(), options.collapsibleState);\n    this.parent = options.parent;\n    this.description = uri.path.replace(\n      vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,\n      ''\n    );\n    this.iconPath = new vscode.ThemeIcon('link');\n  }\n}\n\nexport class ResourceTreeItem extends UriTreeItem {\n  iconPath = vscode.ThemeIcon.File;\n  contextValue = 'foam.resource';\n\n  constructor(\n    public readonly resource: Resource,\n    private readonly workspace: FoamWorkspace,\n    options: {\n      collapsibleState?: vscode.TreeItemCollapsibleState;\n      parent?: vscode.TreeItem;\n    } = {}\n  ) {\n    super(resource.uri, {\n      title: resource.title,\n      collapsibleState: options.collapsibleState,\n      parent: options.parent,\n    });\n    this.command = {\n      command: 'vscode.open',\n      arguments: [toVsCodeUri(resource.uri)],\n      title: 'Go to location',\n    };\n    this.resourceUri = toVsCodeUri(resource.uri);\n  }\n\n  async resolveTreeItem(): Promise<ResourceTreeItem> {\n    if (this instanceof ResourceTreeItem) {\n      const content = await this.workspace.readAsMarkdown(this.resource.uri);\n      this.tooltip = isSome(content)\n        ? getNoteTooltip(content)\n        : this.resource.title;\n    }\n    return this;\n  }\n}\n\nexport class ResourceRangeTreeItem extends BaseTreeItem {\n  public value: any;\n  constructor(\n    public label: string,\n    public variant: string,\n    public readonly resource: Resource,\n    public readonly range: Range,\n    public readonly workspace: FoamWorkspace\n  ) {\n    super(label, vscode.TreeItemCollapsibleState.None);\n    this.command = {\n      command: 'vscode.open',\n      arguments: [toVsCodeUri(resource.uri), { selection: range }],\n      title: 'Go to location',\n    };\n  }\n\n  async resolveTreeItem(): Promise<ResourceRangeTreeItem> {\n    const markdown =\n      (await this.workspace.readAsMarkdown(this.resource.uri)) ?? '';\n    let { block, nLines } = getBlockFor(markdown, this.range.start);\n    // Long blocks need to be interrupted or they won't display in hover preview\n    // We keep the extra lines so that the count in the preview is correct\n    if (nLines > 15) {\n      let tmp = block.split('\\n');\n      tmp.splice(15, 1, '\\n'); // replace a line with a blank line to interrupt the block\n      block = tmp.join('\\n');\n    }\n    const tooltip = getNoteTooltip(block ?? this.label ?? '');\n    this.tooltip = tooltip;\n    return Promise.resolve(this);\n  }\n\n  static icons = {\n    backlink: new vscode.ThemeIcon(\n      'arrow-left',\n      new vscode.ThemeColor('charts.purple')\n    ),\n    link: new vscode.ThemeIcon(\n      'arrow-right',\n      new vscode.ThemeColor('charts.purple')\n    ),\n    tag: new vscode.ThemeIcon(\n      'symbol-number',\n      new vscode.ThemeColor('charts.purple')\n    ),\n  };\n  static async createStandardItem(\n    workspace: FoamWorkspace,\n    resource: Resource,\n    range: Range,\n    variant: 'backlink' | 'tag' | 'link'\n  ): Promise<ResourceRangeTreeItem> {\n    const markdown = (await workspace.readAsMarkdown(resource.uri)) ?? '';\n    const lines = markdown.split('\\n');\n\n    const line = lines[range.start.line];\n    const start = Math.max(0, range.start.character - 15);\n    const ellipsis = start === 0 ? '' : '...';\n\n    const label = line\n      ? `${range.start.line + 1}: ${ellipsis}${line.slice(start, start + 300)}`\n      : Range.toString(range);\n\n    const item = new ResourceRangeTreeItem(\n      label,\n      variant,\n      resource,\n      range,\n      workspace\n    );\n    item.iconPath = ResourceRangeTreeItem.icons[variant];\n\n    return item;\n  }\n}\n\nexport const groupRangesByResource = async (\n  workspace: FoamWorkspace,\n  items:\n    | ResourceRangeTreeItem[]\n    | Promise<ResourceRangeTreeItem[]>\n    | Promise<ResourceRangeTreeItem>[],\n  collapsibleState = vscode.TreeItemCollapsibleState.Collapsed\n) => {\n  let itemsArray = [] as ResourceRangeTreeItem[];\n  if (items instanceof Promise) {\n    itemsArray = await items;\n  }\n  if (items instanceof Array && items[0] instanceof Promise) {\n    itemsArray = await Promise.all(items);\n  }\n  if (items instanceof Array && items[0] instanceof ResourceRangeTreeItem) {\n    itemsArray = items as any;\n  }\n  const byResource = groupBy(itemsArray, item => item.resource.uri.path);\n  const resourceItems = Object.values(byResource).map(items => {\n    const resourceItem = new ResourceTreeItem(items[0].resource, workspace, {\n      collapsibleState,\n    });\n    const children = items.sort((a, b) => Range.isBefore(a.range, b.range));\n    resourceItem.getChildren = () => Promise.resolve(children);\n    resourceItem.description = `(${items.length}) ${resourceItem.description}`;\n    resourceItem.command = children[0].command;\n    return resourceItem;\n  });\n  resourceItems.sort((a, b) => Resource.sortByTitle(a.resource, b.resource));\n  return resourceItems;\n};\n\nexport function createBacklinkItemsForResource(\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  uri: URI,\n  variant: 'backlink' | 'link' = 'backlink'\n) {\n  const connections = graph\n    .getConnections(uri)\n    .filter(c => c.target.asPlain().isEqual(uri));\n\n  const backlinkItems = connections.map(async c =>\n    ResourceRangeTreeItem.createStandardItem(\n      workspace,\n      workspace.get(c.source),\n      c.link.range,\n      variant\n    )\n  );\n  return Promise.all(backlinkItems);\n}\n\nexport function createConnectionItemsForResource(\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  uri: URI,\n  filter: (c: Connection) => boolean = () => true\n) {\n  const connections = graph.getConnections(uri).filter(c => filter(c));\n\n  const backlinkItems = connections.map(async c => {\n    const item = await ResourceRangeTreeItem.createStandardItem(\n      workspace,\n      workspace.get(c.source),\n      c.link.range,\n      c.source.asPlain().isEqual(uri) ? 'link' : 'backlink'\n    );\n    item.value = c;\n    return item;\n  });\n  return Promise.all(backlinkItems);\n}\n\n/**\n * Expands a node and its children in a tree view that match a given predicate\n *\n * @param treeView - The tree view to expand nodes in\n * @param provider - The tree data provider for the view\n * @param element - The element to expand\n * @param when - A function that returns true if the node should be expanded\n */\nexport async function expandNode<T>(\n  treeView: vscode.TreeView<any>,\n  provider: vscode.TreeDataProvider<T>,\n  element: T,\n  when: (element: T) => boolean\n) {\n  try {\n    if (when(element)) {\n      await treeView.reveal(element, {\n        select: false,\n        focus: false,\n        expand: true,\n      });\n    }\n  } catch (e) {\n    const obj = element as any;\n    const label = obj.label ?? obj.toString();\n    Logger.warn(\n      `Could not expand element: ${label}. Try setting the ID property of the TreeItem`\n    );\n  }\n\n  const children = await provider.getChildren(element);\n  for (const child of children) {\n    await expandNode(treeView, provider, child, when);\n  }\n}\n\n/**\n * Expands all items in a tree view that match a given predicate\n *\n * @param treeView - The tree view to expand items in\n * @param provider - The tree data provider for the view\n * @param when - A function that returns true if the node should be expanded\n */\nexport async function expandAll<T>(\n  treeView: vscode.TreeView<T>,\n  provider: vscode.TreeDataProvider<T>,\n  when: (element: T) => boolean = () => true\n) {\n  const elements = await provider.getChildren(undefined);\n  for (const element of elements) {\n    await expandNode(treeView, provider, element, when);\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/block-anchor-ids.test.ts",
    "content": "import MarkdownIt from 'markdown-it';\nimport { markdownItBlockAnchorIds } from './block-anchor-ids';\n\nconst md = markdownItBlockAnchorIds(MarkdownIt());\n\ndescribe('block anchor id injection', () => {\n  describe('id attribute on block elements', () => {\n    it('adds id to a paragraph with a block anchor', () => {\n      // id uses bare blockId (no '^') so CSS querySelector doesn't throw\n      expect(md.render('Some text ^myblock')).toBe(\n        '<p id=\"__myblock\">Some text</p>\\n'\n      );\n    });\n\n    it('adds id to the last line of a multi-line paragraph', () => {\n      expect(md.render('Line one\\nLine two ^multiblock')).toBe(\n        '<p id=\"__multiblock\">Line one\\nLine two</p>\\n'\n      );\n    });\n\n    it('adds id to a tight list item', () => {\n      expect(md.render('- Item ^listblock')).toBe(\n        '<ul>\\n<li id=\"__listblock\">Item</li>\\n</ul>\\n'\n      );\n    });\n\n    it('adds id to the paragraph inside a blockquote', () => {\n      expect(md.render('> Quote text ^quoteblock')).toBe(\n        '<blockquote>\\n<p id=\"__quoteblock\">Quote text</p>\\n</blockquote>\\n'\n      );\n    });\n\n    it('inserts a standalone anchor before a heading instead of setting id', () => {\n      const result = md.render('## My Heading ^headingblock');\n      expect(result).toContain(\n        '<a id=\"__headingblock\" aria-hidden=\"true\"></a>'\n      );\n      expect(result).toContain('<h2>My Heading</h2>');\n      // The anchor must appear before the heading tag\n      expect(result.indexOf('<a id=\"headingblock\"')).toBeLessThan(\n        result.indexOf('<h2>')\n      );\n    });\n\n    it('supports hyphens in block IDs', () => {\n      expect(md.render('A paragraph ^my-block-id')).toBe(\n        '<p id=\"__my-block-id\">A paragraph</p>\\n'\n      );\n    });\n  });\n\n  describe('stripping the marker from visible text', () => {\n    it('strips the ^id marker from paragraph text', () => {\n      expect(md.render('Visible text ^hidden-id')).toBe(\n        '<p id=\"__hidden-id\">Visible text</p>\\n'\n      );\n    });\n\n    it('strips the ^id marker from list item text', () => {\n      expect(md.render('- Item label ^listid')).toBe(\n        '<ul>\\n<li id=\"__listid\">Item label</li>\\n</ul>\\n'\n      );\n    });\n\n    it('strips the ^id marker from heading text but keeps the anchor element', () => {\n      const result = md.render('## Heading ^headid');\n      expect(result).toContain('<a id=\"__headid\" aria-hidden=\"true\"></a>');\n      expect(result).toContain('<h2>Heading</h2>');\n      expect(result).not.toContain('Heading ^headid');\n    });\n  });\n\n  describe('non-interference', () => {\n    it('does not add id to a paragraph without a block anchor', () => {\n      expect(md.render('Just a paragraph')).toBe('<p>Just a paragraph</p>\\n');\n    });\n\n    it('does not treat ^id in the middle of a paragraph as an anchor', () => {\n      const result = md.render('Text ^notanid more text after');\n      expect(result).toBe('<p>Text ^notanid more text after</p>\\n');\n    });\n\n    it('does not interfere with multiple blocks in the same document', () => {\n      const result = md.render(\n        'First ^first\\n\\nSecond ^second\\n\\nNo anchor here'\n      );\n      expect(result).toContain('<p id=\"__first\">First</p>');\n      expect(result).toContain('<p id=\"__second\">Second</p>');\n      expect(result).toContain('<p>No anchor here</p>');\n    });\n  });\n\n  describe('full-line block IDs (Obsidian-compatible)', () => {\n    it('inserts anchor before a code fence with a standalone ^id paragraph after it', () => {\n      const result = md.render('```js\\nconsole.log(\"hi\");\\n```\\n^mycode');\n      expect(result).toContain('<a id=\"__mycode\" aria-hidden=\"true\"></a>');\n      expect(result).toContain('<code class=\"language-js\">');\n      // The standalone ^id paragraph must not appear in the output\n      expect(result).not.toContain('^mycode');\n      // Anchor must appear before the code block\n      expect(result.indexOf('<a id=\"__mycode\"')).toBeLessThan(\n        result.indexOf('<code')\n      );\n    });\n\n    it('treats standalone ^id as anchor when separated from fence by one blank line', () => {\n      const result = md.render('```\\ncode\\n```\\n\\n^mycode');\n      expect(result).toContain('<a id=\"__mycode\" aria-hidden=\"true\"></a>');\n      expect(result).not.toContain('^mycode');\n    });\n\n    it('does not treat standalone ^id as anchor when separated from fence by two blank lines', () => {\n      const result = md.render('```\\ncode\\n```\\n\\n\\n^mycode');\n      expect(result).not.toContain('<a id=\"__mycode\"');\n    });\n\n    it('removes the ^id standalone paragraph and anchors the table when separated by one blank line', () => {\n      const result = md.render('| A | B |\\n| - | - |\\n| 1 | 2 |\\n\\n^mytable');\n      expect(result).toContain('<a id=\"__mytable\" aria-hidden=\"true\"></a>');\n      expect(result).toContain('<table>');\n      expect(result).not.toContain('^mytable');\n      expect(result.indexOf('<a id=\"__mytable\"')).toBeLessThan(\n        result.indexOf('<table>')\n      );\n    });\n\n    it('removes the ^id table row and inserts an anchor before the table', () => {\n      const result = md.render('| A | B |\\n| - | - |\\n| 1 | 2 |\\n^mytable');\n      expect(result).toContain('<a id=\"__mytable\" aria-hidden=\"true\"></a>');\n      expect(result).toContain('<table>');\n      // The ^id row must not appear as a table cell\n      expect(result).not.toContain('^mytable');\n      // Anchor must appear before the table\n      expect(result.indexOf('<a id=\"__mytable\"')).toBeLessThan(\n        result.indexOf('<table>')\n      );\n    });\n\n    it('strips ^id from the last list item and sets id on the list element', () => {\n      const result = md.render('- Item one\\n- Item two\\n^mylist');\n      expect(result).toContain('<ul id=\"__mylist\">');\n      expect(result).not.toContain('^mylist');\n    });\n\n    it('strips ^id from the last ordered list item and sets id on the list', () => {\n      const result = md.render('1. First\\n2. Second\\n^orderedlist');\n      expect(result).toContain('<ol id=\"__orderedlist\">');\n      expect(result).not.toContain('^orderedlist');\n    });\n\n    describe('blockquote block IDs', () => {\n      it('anchors a blockquote when ^id is on its own line right after (lazy continuation)', () => {\n        // markdown-it absorbs ^id into the blockquote via lazy continuation,\n        // so the id ends up as an attribute on <blockquote> rather than a\n        // standalone <a> element (which is used for the blank-line case).\n        const result = md.render('> First line\\n> Second line\\n^myquote');\n        expect(result).toContain('id=\"__myquote\"');\n        expect(result).not.toContain('^myquote');\n      });\n\n      it('inserts anchor before a blockquote when ^id is separated by one blank line', () => {\n        const result = md.render('> First line\\n> Second line\\n\\n^myquote');\n        expect(result).toContain('<a id=\"__myquote\" aria-hidden=\"true\"></a>');\n        expect(result).not.toContain('^myquote');\n        expect(result.indexOf('<a id=\"__myquote\"')).toBeLessThan(\n          result.indexOf('<blockquote>')\n        );\n      });\n\n      it('sets an anchor when ^id is the last line inside the blockquote', () => {\n        const result = md.render('> First line\\n> Second line\\n> ^myquote');\n        expect(result).toContain('id=\"__myquote\"');\n        expect(result).not.toContain('^myquote');\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/block-anchor-ids.ts",
    "content": "/*global markdownit:readonly*/\n\n/**\n * markdown-it plugin that adds HTML `id` attributes to block elements\n * carrying a `^blockid` anchor marker.\n *\n * This enables in-preview fragment navigation: clicking `[[note#^myblock]]`\n * in the preview scrolls directly to the target block.\n *\n * Two syntaxes are handled:\n *\n * 1. Inline marker — `^id` at the end of the block's own line:\n *    `Some text ^myblock`\n *    `- List item ^myblock`\n *    The id is set on the opening HTML element (paragraph, list item, etc.).\n *    For headings a separate `<a id>` anchor is inserted before the heading tag\n *    to preserve the auto-generated slug id used by section links.\n *\n * 2. Full-line marker — `^id` on its own line immediately after the block:\n *    ```\n *    code\n *    ```\n *    ^mycode\n *\n *    | table |\n *    ^mytable\n *\n *    - item 1\n *    - item 2\n *    ^mylist\n *\n *    For code fences the standalone `^id` paragraph is removed and an anchor\n *    is inserted before the fence.  For tables markdown-it absorbs the `^id`\n *    as a table row, so we detect and remove that row and anchor the table.\n *    For lists the `^id` is absorbed into the last item's inline content\n *    (as `\"last item text\\n^id\"`); we strip it and anchor the list element.\n *\n * The `__` prefix on ids minimises collisions with section-based ids and\n * avoids the `^` character which is not a valid CSS identifier character.\n * The ` ^blockid` marker is stripped from the visible rendered text.\n */\n\n// Matches a trailing inline block anchor: ` ^blockid` at end of content.\nconst INLINE_ANCHOR_RE = /\\s\\^([a-zA-Z0-9-]+)$/;\n// Matches a standalone full-line block anchor paragraph: only `^blockid`.\nconst FULL_LINE_ANCHOR_RE = /^\\^([a-zA-Z0-9-]+)$/;\n// Matches a trailing own-line block anchor inside multiline inline content.\nconst TRAILING_OWN_LINE_ANCHOR_RE = /\\n\\^([a-zA-Z0-9-]+)$/;\n\n/** Insert an `<a id>` anchor token before position `idx`. */\nfunction insertAnchor(\n  state: any,\n  tokens: any[],\n  idx: number,\n  blockId: string\n): void {\n  const anchor = new state.Token('html_block', '', 0);\n  anchor.content = `<a id=\"__${blockId}\" aria-hidden=\"true\"></a>\\n`;\n  tokens.splice(idx, 0, anchor);\n}\n\n/**\n * Walk backward from `startIdx` to find the nearest open token of one of the\n * given types, accounting for nesting depth.\n */\nfunction findMatchingOpen(\n  tokens: any[],\n  startIdx: number,\n  openType: string,\n  closeType: string\n): number {\n  let depth = 0;\n  for (let j = startIdx; j >= 0; j--) {\n    if (tokens[j].type === closeType) {\n      depth++;\n    } else if (tokens[j].type === openType) {\n      if (depth === 0) {\n        return j;\n      }\n      depth--;\n    }\n  }\n  return -1;\n}\n\nexport const markdownItBlockAnchorIds = (md: markdownit) => {\n  md.core.ruler.push('block-anchor-ids', state => {\n    const tokens = state.tokens;\n\n    // ── Pass 1: inline markers ──────────────────────────────────────────────\n    // Iterate in reverse so splice insertions don't affect unvisited indices.\n    for (let i = tokens.length - 1; i >= 1; i--) {\n      const token = tokens[i];\n      if (token.type !== 'inline') {\n        continue;\n      }\n\n      // Skip tokens whose content is ONLY a full-line anchor — those are\n      // handled by Pass 2 below (code/table) or Pass 3 (list/blockquote).\n      if (FULL_LINE_ANCHOR_RE.test(token.content)) {\n        continue;\n      }\n\n      // Skip tokens whose content ends with a own-line anchor (`\\n^id`).\n      // These are absorbed list full-line IDs; Pass 3 handles them.\n      if (TRAILING_OWN_LINE_ANCHOR_RE.test(token.content)) {\n        continue;\n      }\n\n      const match = token.content.match(INLINE_ANCHOR_RE);\n      if (!match) {\n        continue;\n      }\n\n      const blockId = match[1];\n      let openToken = tokens[i - 1];\n\n      // Only act when the preceding token is a block-level opening tag.\n      if (!openToken || openToken.nesting !== 1) {\n        continue;\n      }\n\n      // A paragraph_open inside a tight list item is rendered with hidden=true\n      // (no <p> tag is emitted), so any attribute set on it would be lost.\n      // Walk back to find the enclosing list_item_open instead.\n      if (openToken.hidden) {\n        let found = false;\n        for (let j = i - 2; j >= 0; j--) {\n          if (tokens[j].nesting === 1 && !tokens[j].hidden) {\n            openToken = tokens[j];\n            found = true;\n            break;\n          }\n        }\n        if (!found) {\n          continue;\n        }\n      }\n\n      if (openToken.type === 'heading_open') {\n        // Insert a standalone anchor before the heading so we don't overwrite\n        // the heading's auto-generated slug id (used by [[note#Section]] links).\n        insertAnchor(state, tokens, i - 1, blockId);\n        // i - 1 was just used for the heading_open; after splice all indices\n        // >= i-1 shift by +1, but since we iterate in reverse this is fine.\n      } else {\n        openToken.attrSet('id', `__${blockId}`);\n      }\n\n      // Strip the ^id marker from the rendered inline children.\n      if (token.children) {\n        for (let k = token.children.length - 1; k >= 0; k--) {\n          const child = token.children[k];\n          if (child.type === 'text') {\n            const stripped = child.content.replace(INLINE_ANCHOR_RE, '');\n            if (stripped !== child.content) {\n              child.content = stripped;\n              token.content = token.content.replace(INLINE_ANCHOR_RE, '');\n              break;\n            }\n          }\n        }\n      }\n    }\n\n    // ── Pass 2: full-line anchor paragraph after a fence or table ─────────\n    // Handles the case where `^id` is a standalone paragraph following a fence\n    // or a table. With no blank line, tables absorb `^id` as a row (Pass 3);\n    // with a blank line, `^id` becomes a standalone paragraph caught here.\n    // One blank line between the block and `^id` is tolerated so that markdown\n    // formatters that insert blank lines around code/table blocks still work.\n    for (let i = tokens.length - 1; i >= 2; i--) {\n      const token = tokens[i];\n      if (token.type !== 'inline') {\n        continue;\n      }\n      const idMatch = token.content.match(FULL_LINE_ANCHOR_RE);\n      if (!idMatch) {\n        continue;\n      }\n      // Must be wrapped in a paragraph: paragraph_open at i-1.\n      if (tokens[i - 1]?.type !== 'paragraph_open') {\n        continue;\n      }\n      // paragraph_close must follow at i+1.\n      if (tokens[i + 1]?.type !== 'paragraph_close') {\n        continue;\n      }\n\n      const prevToken = tokens[i - 2];\n      const paraStart = tokens[i - 1].map?.[0];\n      let anchorBeforeIdx = -1;\n\n      if (prevToken?.type === 'fence') {\n        // Allow up to one blank line between the fence and the ^id paragraph.\n        const fenceEnd = prevToken.map?.[1];\n        if (\n          fenceEnd !== undefined &&\n          paraStart !== undefined &&\n          paraStart > fenceEnd + 1\n        ) {\n          continue;\n        }\n        anchorBeforeIdx = i - 2;\n      } else if (prevToken?.type === 'table_close') {\n        // ^id as standalone paragraph after a table (blank-line case).\n        const tableOpenIdx = findMatchingOpen(\n          tokens,\n          i - 3,\n          'table_open',\n          'table_close'\n        );\n        if (tableOpenIdx === -1) {\n          continue;\n        }\n        // Allow up to one blank line.\n        const tableEnd = tokens[tableOpenIdx].map?.[1];\n        if (\n          tableEnd !== undefined &&\n          paraStart !== undefined &&\n          paraStart > tableEnd + 1\n        ) {\n          continue;\n        }\n        anchorBeforeIdx = tableOpenIdx;\n      } else if (prevToken?.type === 'blockquote_close') {\n        // ^id as standalone paragraph after a blockquote (blank-line case).\n        const bqOpenIdx = findMatchingOpen(\n          tokens,\n          i - 3,\n          'blockquote_open',\n          'blockquote_close'\n        );\n        if (bqOpenIdx === -1) {\n          continue;\n        }\n        // Allow up to one blank line.\n        const bqEnd = tokens[bqOpenIdx].map?.[1];\n        if (\n          bqEnd !== undefined &&\n          paraStart !== undefined &&\n          paraStart > bqEnd + 1\n        ) {\n          continue;\n        }\n        anchorBeforeIdx = bqOpenIdx;\n      }\n\n      if (anchorBeforeIdx === -1) {\n        continue;\n      }\n\n      const blockId = idMatch[1];\n      // insertAnchor shifts all tokens at indices >= anchorBeforeIdx up by 1,\n      // so the paragraph_open is now at i, inline at i+1, paragraph_close at i+2.\n      insertAnchor(state, tokens, anchorBeforeIdx, blockId);\n      tokens.splice(i, 3);\n      i -= 2;\n    }\n\n    // ── Pass 3: full-line anchor absorbed into table or list ───────────────\n    // For tables: markdown-it parses the `^id` line as a table row, placing\n    // the anchor text in the first <td>. We detect this, remove the row, and\n    // anchor the table.\n    // For lists: the `^id` appears as `\"last item\\n^id\"` in the last item's\n    // inline token. We strip it and anchor the surrounding list element.\n    for (let i = tokens.length - 1; i >= 1; i--) {\n      const token = tokens[i];\n      if (token.type !== 'inline') {\n        continue;\n      }\n\n      // Table case: inline content is only \"^id\" and is inside a td_open.\n      const tableIdMatch = token.content.match(FULL_LINE_ANCHOR_RE);\n      if (tableIdMatch && tokens[i - 1]?.type === 'td_open') {\n        const blockId = tableIdMatch[1];\n\n        // Find the tr_open that starts the row containing this td.\n        const trOpenIdx = findMatchingOpen(\n          tokens,\n          i - 1,\n          'tr_open',\n          'tr_close'\n        );\n        if (trOpenIdx === -1) {\n          continue;\n        }\n\n        // Find the tr_close that ends this row.\n        let trCloseIdx = -1;\n        for (let j = i + 1; j < tokens.length; j++) {\n          if (tokens[j].type === 'tr_close') {\n            trCloseIdx = j;\n            break;\n          }\n        }\n        if (trCloseIdx === -1) {\n          continue;\n        }\n\n        // Find table_open to insert anchor before it.\n        const tableOpenIdx = findMatchingOpen(\n          tokens,\n          trOpenIdx - 1,\n          'table_open',\n          'table_close'\n        );\n        if (tableOpenIdx === -1) {\n          continue;\n        }\n\n        // Remove the row (tr_open … tr_close inclusive) — work from the end.\n        tokens.splice(trOpenIdx, trCloseIdx - trOpenIdx + 1);\n        // Insert anchor before table_open (index unchanged since we removed after it).\n        insertAnchor(state, tokens, tableOpenIdx, blockId);\n        // Adjust i to account for removals.\n        i = trOpenIdx - 1;\n        continue;\n      }\n\n      // List/blockquote case: inline content ends with \"\\n^id\".\n      // For lists: the ^id is absorbed into the last list item's inline content.\n      // For blockquotes: the ^id is on its own `> ^id` line or lazy-continuation\n      // line immediately after the blockquote (no blank line).\n      const ownLineMatch = token.content.match(TRAILING_OWN_LINE_ANCHOR_RE);\n      if (ownLineMatch) {\n        const blockId = ownLineMatch[1];\n\n        // Walk backward to find the nearest enclosing block container.\n        // We stop at blockquote_open, bullet_list_open, or ordered_list_open,\n        // accounting for nesting depth of all three types.\n        let containerOpenIdx = -1;\n        let containerType = '';\n        let depth = 0;\n        for (let j = i - 1; j >= 0; j--) {\n          const t = tokens[j];\n          if (\n            t.type === 'blockquote_close' ||\n            t.type === 'bullet_list_close' ||\n            t.type === 'ordered_list_close'\n          ) {\n            depth++;\n          } else if (\n            t.type === 'blockquote_open' ||\n            t.type === 'bullet_list_open' ||\n            t.type === 'ordered_list_open'\n          ) {\n            if (depth === 0) {\n              containerOpenIdx = j;\n              containerType = t.type;\n              break;\n            }\n            depth--;\n          }\n        }\n        if (containerOpenIdx === -1) {\n          continue;\n        }\n\n        if (containerType !== 'blockquote_open') {\n          // List: verify a matching close exists after the inline token.\n          const listCloseType =\n            containerType === 'bullet_list_open'\n              ? 'bullet_list_close'\n              : 'ordered_list_close';\n          let listCloseIdx = -1;\n          for (let j = i + 1; j < tokens.length; j++) {\n            if (tokens[j].type === listCloseType) {\n              listCloseIdx = j;\n              break;\n            }\n          }\n          if (listCloseIdx === -1) {\n            continue;\n          }\n        }\n\n        // Set the id on the container's open token.\n        tokens[containerOpenIdx].attrSet('id', `__${blockId}`);\n\n        // Strip \"\\n^id\" from the inline content and children.\n        // The \\n is a softbreak child token and ^id is a separate text child,\n        // so we search for the text(\"^id\") child and remove it + the preceding break.\n        token.content = token.content.replace(TRAILING_OWN_LINE_ANCHOR_RE, '');\n        if (token.children) {\n          for (let k = token.children.length - 1; k >= 0; k--) {\n            const child = token.children[k];\n            if (child.type === 'text') {\n              // Case A: the \\n^id is embedded in the text value itself.\n              const stripped = child.content.replace(\n                TRAILING_OWN_LINE_ANCHOR_RE,\n                ''\n              );\n              if (stripped !== child.content) {\n                child.content = stripped;\n                break;\n              }\n              // Case B: text child is just \"^id\" (no leading whitespace) with\n              // a softbreak/hardbreak immediately before it.\n              if (\n                /^\\^[a-zA-Z0-9-]+$/.test(child.content) &&\n                k > 0 &&\n                (token.children[k - 1].type === 'softbreak' ||\n                  token.children[k - 1].type === 'hardbreak')\n              ) {\n                token.children.splice(k - 1, 2);\n                break;\n              }\n            }\n          }\n        }\n      }\n    }\n  });\n\n  return md;\n};\n\nexport default markdownItBlockAnchorIds;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/escape-wikilink-pipes.test.ts",
    "content": "/* @unit-ready */\nimport MarkdownIt from 'markdown-it';\nimport {\n  default as escapeWikilinkPipes,\n  PIPE_PLACEHOLDER,\n} from './escape-wikilink-pipes';\n\ndescribe('escape-wikilink-pipes plugin', () => {\n  it('should render table with wikilink alias correctly', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Column |\n| --- |\n| [[note|alias]] |`;\n\n    const html = md.render(markdown);\n\n    // Should have proper table structure\n    expect(html).toContain('<table>');\n    expect(html).toContain('<tbody>');\n\n    // Should preserve the wikilink with pipe character intact\n    expect(html).toContain('[[note|alias]]');\n\n    // Should NOT split into multiple cells (would see extra <td> tags)\n    const tdCount = (html.match(/<td>/g) || []).length;\n    expect(tdCount).toBe(1);\n  });\n\n  it('should render table with multiple wikilink aliases in same row', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Col1 | Col2 | Col3 |\n| --- | --- | --- |\n| [[a|A]] | [[b|B]] | [[c|C]] |`;\n\n    const html = md.render(markdown);\n\n    // All three wikilinks should be preserved\n    expect(html).toContain('[[a|A]]');\n    expect(html).toContain('[[b|B]]');\n    expect(html).toContain('[[c|C]]');\n\n    // Should have exactly 3 cells in body row\n    const bodyMatch = html.match(/<tbody>(.*?)<\\/tbody>/s);\n    expect(bodyMatch).toBeTruthy();\n    const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;\n    expect(bodyCells).toBe(3);\n  });\n\n  it('should render table with wikilink containing section and alias', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Link |\n| --- |\n| [[note#section|alias text]] |`;\n\n    const html = md.render(markdown);\n\n    // Wikilink with section and alias should be intact\n    expect(html).toContain('[[note#section|alias text]]');\n\n    // Should be in a single cell\n    const bodyMatch = html.match(/<tbody>(.*?)<\\/tbody>/s);\n    const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;\n    expect(bodyCells).toBe(1);\n  });\n\n  it('should render table with embed wikilink alias correctly', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Embed |\n| --- |\n| ![[image|caption]] |`;\n\n    const html = md.render(markdown);\n\n    // Embed wikilink should be preserved\n    expect(html).toContain('![[image|caption]]');\n  });\n\n  it('should not affect wikilinks without aliases', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Link |\n| --- |\n| [[note-without-alias]] |`;\n\n    const html = md.render(markdown);\n\n    // Regular wikilink should still work\n    expect(html).toContain('[[note-without-alias]]');\n  });\n\n  it('should not affect wikilinks outside of tables', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `\nParagraph with [[note|alias]] link.\n\n| Column |\n| --- |\n| [[table-note|table-alias]] |\n\nAnother [[note2|alias2]] paragraph.\n`;\n\n    const html = md.render(markdown);\n\n    // All wikilinks should be preserved\n    expect(html).toContain('[[note|alias]]');\n    expect(html).toContain('[[table-note|table-alias]]');\n    expect(html).toContain('[[note2|alias2]]');\n  });\n\n  it('should handle table with mixed content and wikilinks', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Text | Link | Mixed |\n| --- | --- | --- |\n| plain text | [[note|alias]] | text [[link|L]] more |`;\n\n    const html = md.render(markdown);\n\n    // Both wikilinks should be preserved\n    expect(html).toContain('[[note|alias]]');\n    expect(html).toContain('[[link|L]]');\n\n    // Should have 3 cells\n    const bodyMatch = html.match(/<tbody>(.*?)<\\/tbody>/s);\n    const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;\n    expect(bodyCells).toBe(3);\n  });\n\n  it('should handle tables without wikilinks', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Col1 | Col2 |\n| --- | --- |\n| text | more |`;\n\n    const html = md.render(markdown);\n\n    // Should render normal table\n    expect(html).toContain('<table>');\n    expect(html).toContain('text');\n    expect(html).toContain('more');\n  });\n\n  it('should not leave placeholder character in rendered output', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Col1 | Col2 |\n| --- | --- |\n| [[a|A]] | [[b|B]] |`;\n\n    const html = md.render(markdown);\n\n    // Should not contain the internal placeholder\n    expect(html).not.toContain(PIPE_PLACEHOLDER);\n  });\n\n  it('should handle complex wikilink aliases with special characters', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Link |\n| --- |\n| [[note-with-dashes|Alias with spaces & special!]] |`;\n\n    const html = md.render(markdown);\n\n    expect(html).toContain(\n      '[[note-with-dashes|Alias with spaces &amp; special!]]'\n    );\n  });\n\n  it('should handle multiple rows with wikilink aliases', () => {\n    const md = MarkdownIt();\n    escapeWikilinkPipes(md);\n\n    const markdown = `| Links |\n| --- |\n| [[note1|alias1]] |\n| [[note2|alias2]] |\n| [[note3|alias3]] |`;\n\n    const html = md.render(markdown);\n\n    // All three should be preserved\n    expect(html).toContain('[[note1|alias1]]');\n    expect(html).toContain('[[note2|alias2]]');\n    expect(html).toContain('[[note3|alias3]]');\n\n    // Should have 3 rows in tbody\n    const bodyMatch = html.match(/<tbody>(.*?)<\\/tbody>/s);\n    const bodyRows = (bodyMatch[0].match(/<tr>/g) || []).length;\n    expect(bodyRows).toBe(3);\n  });\n\n  it('should work when markdown-it does not have table support', () => {\n    const md = MarkdownIt();\n    md.disable(['table']);\n\n    // Should not throw when table rule doesn't exist\n    expect(() => escapeWikilinkPipes(md)).not.toThrow();\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/escape-wikilink-pipes.ts",
    "content": "/*global markdownit:readonly*/\n\n/**\n * Markdown-it plugin to handle wikilink aliases in tables by wrapping the table parser.\n *\n * This plugin addresses issue #1544 where wikilink aliases (e.g., [[note|alias]])\n * are incorrectly split into separate table cells because the pipe character `|`\n * is used both as a wikilink alias separator and a table column separator.\n *\n * The plugin works by wrapping the table block parser:\n * 1. Before the table parser runs, temporarily replace pipes in wikilinks with a placeholder\n * 2. Let the table parser create the table structure and inline tokens\n * 3. After the table parser returns, restore pipes in the inline token content\n * 4. Later inline parsing will see the correct wikilink syntax with pipes\n *\n * This approach keeps all encoding/decoding logic localized to this single function,\n * making it invisible to the rest of the codebase.\n */\n\n// Unique placeholder that's unlikely to appear in normal markdown text\n// Note: We've tested various text-based placeholders but all fail:\n// - \"___FOAM_ALIAS_DIVIDER___\" - underscores interpreted as emphasis markers\n// - \"FOAM__INTERNAL__...\" - double underscores cause strong emphasis issues\n// - \"FOAMINTERNALALIASDIVIDERPLACEHOLDER\" - gets truncated (output: \"[[noteFOAMINTERN\")\n// Solution: Use a single Unicode character (U+F8FF Private Use Area) that:\n// - Has no markdown meaning\n// - Won't be split or modified by parsers\n// - Is extremely unlikely to appear in user content\nexport const PIPE_PLACEHOLDER = '\\uF8FF';\n\n/**\n * Regex to match wikilinks with pipes (aliases or multiple pipes)\n * Matches:\n * - [[note|alias]]\n * - ![[note|alias]] (embeds)\n * - [[note#section|alias]]\n */\nconst WIKILINK_WITH_PIPE_REGEX = /!?\\[\\[([^\\]]*?\\|[^\\]]*?)\\]\\]/g;\n\n/**\n * Replace pipes within wikilinks with placeholder\n */\nfunction encodePipesInWikilinks(text: string): string {\n  return text.replace(WIKILINK_WITH_PIPE_REGEX, match => {\n    return match.replace(/\\|/g, PIPE_PLACEHOLDER);\n  });\n}\n\n/**\n * Restore pipes from placeholder in text\n */\nfunction decodePipesInWikilinks(text: string): string {\n  return text.replace(new RegExp(PIPE_PLACEHOLDER, 'g'), '|');\n}\n\nexport const escapeWikilinkPipes = (md: markdownit) => {\n  // Get the original table parser function\n  // Note: __find__ and __rules__ are internal APIs but necessary for wrapping\n  const ruler = md.block.ruler as any;\n  const tableRuleIndex = ruler.__find__('table');\n  if (tableRuleIndex === -1) {\n    // Table rule not found (maybe GFM tables not enabled), skip wrapping\n    return md;\n  }\n\n  const originalTableRule = ruler.__rules__[tableRuleIndex].fn;\n\n  // Create wrapped table parser\n  const wrappedTableRule = function (state, startLine, endLine, silent) {\n    // Store the token count before parsing to identify new tokens\n    const tokensBefore = state.tokens.length;\n\n    // 1. ENCODE: Replace pipes in wikilinks with placeholder in source\n    const originalSrc = state.src;\n    state.src = encodePipesInWikilinks(state.src);\n\n    // 2. Call the original table parser\n    // It will create tokens with encoded content (pipes replaced)\n    const result = originalTableRule(state, startLine, endLine, silent);\n\n    // 3. DECODE: Restore pipes in the newly created inline tokens\n    if (result) {\n      // Only process tokens that were created by this table parse\n      for (let i = tokensBefore; i < state.tokens.length; i++) {\n        const token = state.tokens[i];\n        // Inline tokens contain the cell content that needs decoding\n        if (token.type === 'inline' && token.content) {\n          token.content = decodePipesInWikilinks(token.content);\n        }\n      }\n    }\n\n    // 4. Restore original source\n    state.src = originalSrc;\n\n    return result;\n  };\n\n  // Replace the table rule with our wrapped version\n  md.block.ruler.at('table', wrappedTableRule);\n\n  return md;\n};\n\nexport default escapeWikilinkPipes;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/foam-query-renderer.test.ts",
    "content": "import MarkdownIt from 'markdown-it';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FoamGraph } from '../../core/model/graph';\nimport { URI } from '../../core/model/uri';\nimport { createTestNote } from '../../test/test-utils';\nimport { markdownItFoamQuery } from './foam-query-renderer';\n\ndescribe('markdownItFoamQuery', () => {\n  const root = URI.file('/test-workspace/ref.md');\n\n  const noteA = createTestNote({\n    uri: 'notes/alpha.md',\n    title: 'Alpha',\n    tags: ['research'],\n    root,\n  });\n  const noteB = createTestNote({\n    uri: 'notes/beta.md',\n    title: 'Beta',\n    tags: ['research', 'draft'],\n    root,\n  });\n  const noteC = createTestNote({\n    uri: 'notes/gamma.md',\n    title: 'Gamma',\n    tags: ['other'],\n    root,\n  });\n\n  const noteD = createTestNote({\n    uri: 'notes/delta.md',\n    title: 'Delta',\n    tags: ['research'],\n    root,\n    properties: { status: 'published', date: '2024-01-01' },\n  });\n\n  const ws = new FoamWorkspace().set(noteA).set(noteB).set(noteC).set(noteD);\n  const graph = FoamGraph.fromWorkspace(ws, false);\n\n  const workspaceRoot = '/test-workspace';\n  const toRelativePath = (uriPath: string) =>\n    uriPath.startsWith(workspaceRoot)\n      ? uriPath.slice(workspaceRoot.length)\n      : uriPath;\n\n  const md = markdownItFoamQuery(MarkdownIt(), ws, graph, {\n    isTrusted: () => true,\n    toRelativePath,\n  });\n\n  describe('placeholder — empty blocks', () => {\n    it('renders a placeholder for an empty foam-query block', () => {\n      const result = md.render('```foam-query\\n```');\n      expect(result).toContain('foam-query-placeholder');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('renders a placeholder for a whitespace-only foam-query block', () => {\n      const result = md.render('```foam-query\\n   \\n```');\n      expect(result).toContain('foam-query-placeholder');\n    });\n\n    it('placeholder includes a syntax example', () => {\n      const result = md.render('```foam-query\\n```');\n      expect(result).toContain('filter:');\n    });\n\n    it('renders a placeholder for an empty foam-query-js block', () => {\n      const result = md.render('```foam-query-js\\n```');\n      expect(result).toContain('foam-query-placeholder');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('placeholder for foam-query-js includes a code example', () => {\n      const result = md.render('```foam-query-js\\n```');\n      expect(result).toContain('foam.pages');\n    });\n  });\n\n  describe('pass-through', () => {\n    it('leaves regular code fences unchanged', () => {\n      const result = md.render('```typescript\\nconst x = 1;\\n```');\n      expect(result).toContain('const x = 1;');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('leaves regular markdown unchanged', () => {\n      const result = md.render('# Hello\\n\\nsome text');\n      expect(result).toContain('Hello');\n      expect(result).not.toContain('foam-query');\n    });\n  });\n\n  describe('foam-query — DQL blocks', () => {\n    it('renders matching notes as a list by default', () => {\n      const result = md.render('```foam-query\\nfilter: \"#research\"\\n```');\n      expect(result).toContain('<ul class=\"foam-query-results\">');\n      expect(result).toContain('foam-note-link');\n      expect(result).toContain('Alpha');\n      expect(result).toContain('Beta');\n      expect(result).not.toContain('Gamma');\n    });\n\n    it('renders a table when format is table', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nformat: table\\nselect: [title, type]\\n```'\n      );\n      expect(result).toContain('<table class=\"foam-query-results\">');\n      expect(result).toContain('<th>title</th>');\n      expect(result).toContain('<th>type</th>');\n      expect(result).toContain('Alpha');\n      expect(result).toContain('Beta');\n      expect(result).not.toContain('Gamma');\n    });\n\n    it('infers table format when multiple fields are selected', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nselect: [title, type]\\n```'\n      );\n      expect(result).toContain('<table class=\"foam-query-results\">');\n    });\n\n    it('renders count format', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nformat: count\\n```'\n      );\n      expect(result).toContain('3 notes');\n    });\n\n    it('renders singular \"note\" for a single result', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#other\"\\nformat: count\\n```'\n      );\n      expect(result).toContain('1 note');\n      expect(result).not.toContain('1 notes');\n    });\n\n    it('renders \"No results\" message for an empty result set', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#nonexistent-tag\"\\n```'\n      );\n      expect(result).toContain('foam-query-empty');\n    });\n\n    it('generates foam-note-link anchors pointing to the note path', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nsort: title ASC\\nlimit: 1\\n```'\n      );\n      expect(result).toContain('class=\"foam-note-link\"');\n      expect(result).toContain('/notes/alpha.md');\n    });\n\n    it('respects sort ASC', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nsort: title ASC\\n```'\n      );\n      expect(result.indexOf('Alpha')).toBeLessThan(result.indexOf('Beta'));\n    });\n\n    it('respects sort DESC', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nsort: title DESC\\n```'\n      );\n      expect(result.indexOf('Beta')).toBeLessThan(result.indexOf('Alpha'));\n    });\n\n    it('respects limit', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nsort: title ASC\\nlimit: 1\\n```'\n      );\n      expect(result).toContain('Alpha');\n      expect(result).not.toContain('Beta');\n    });\n\n    it('renders the selected field as text when title is not selected', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nselect: [path]\\n```'\n      );\n      expect(result).toContain('<ul class=\"foam-query-results\">');\n      expect(result).toContain('alpha.md');\n      expect(result).toContain('beta.md');\n      expect(result).not.toMatch(/<a[^>]*><\\/a>/);\n    });\n\n    it('renders all notes when filter is omitted', () => {\n      const result = md.render('```foam-query\\nformat: count\\n```');\n      expect(result).toContain('4 notes');\n    });\n\n    it('renders a YAML parse error gracefully when the whole block is invalid', () => {\n      const result = md.render('```foam-query\\n: bad: {\\n```');\n      expect(result).toContain('foam-query-error');\n      expect(result).toContain('YAML parse error');\n      expect(result).toContain('foam-query-placeholder');\n    });\n\n    it('shows placeholder while typing a partial field name (not yet a mapping)', () => {\n      for (const partial of ['f', 'fi', 'filter']) {\n        const result = md.render(`\\`\\`\\`foam-query\\n${partial}\\n\\`\\`\\``);\n        expect(result).toContain('foam-query-placeholder');\n        expect(result).not.toContain('foam-query-results');\n      }\n    });\n\n    it('shows placeholder and a hint when filter has no value', () => {\n      const result = md.render('```foam-query\\nfilter:\\n```');\n      expect(result).toContain('foam-query-placeholder');\n      expect(result).toContain('foam-query-warning');\n      expect(result).toContain('*');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('shows filter hint even when other valid fields are present', () => {\n      const result = md.render('```foam-query\\nfilter:\\nformat: count\\n```');\n      expect(result).toContain('foam-query-placeholder');\n      expect(result).toContain('foam-query-warning');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('renders partial results and a warning when only a later line is invalid', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#research\"\\nformat: [\\n```'\n      );\n      expect(result).toContain('Alpha');\n      expect(result).toContain('Beta');\n      expect(result).not.toContain('Gamma');\n      expect(result).toContain('foam-query-warning');\n    });\n\n    it('falls back to placeholder when no valid content can be recovered', () => {\n      const result = md.render('```foam-query\\n: bad: {\\n: also bad: {\\n```');\n      expect(result).toContain('foam-query-placeholder');\n      expect(result).toContain('foam-query-error');\n      expect(result).not.toContain('foam-query-results');\n    });\n\n    it('renders results and warns about unknown fields', () => {\n      const result = md.render(\n        '```foam-query\\nfliter: \"#research\"\\nformat: count\\n```'\n      );\n      // filter is missing so all notes match\n      expect(result).toContain('4 notes');\n      expect(result).toContain('foam-query-warning');\n      expect(result).toContain('fliter');\n    });\n\n    it('warns once per unknown field', () => {\n      const result = md.render(\n        '```foam-query\\nfliter: \"#research\"\\nsorting: title ASC\\nformat: count\\n```'\n      );\n      expect(result).toContain('fliter');\n      expect(result).toContain('sorting');\n      expect(result).toContain('foam-query-warning');\n    });\n\n    describe('field value validation', () => {\n      it('shows placeholder and warning when filter has wrong type', () => {\n        const result = md.render('```foam-query\\nfilter: [bad]\\n```');\n        expect(result).toContain('foam-query-placeholder');\n        expect(result).toContain('foam-query-warning');\n        expect(result).not.toContain('foam-query-results');\n      });\n\n      it('warns and strips select when it is an empty array', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: []\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('select');\n        expect(result).toContain('Alpha');\n      });\n\n      it('warns and strips select when it is not an array', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: title\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('select');\n        expect(result).toContain('Alpha');\n      });\n\n      it('warns and strips sort when it is not a string', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nsort: 123\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('sort');\n        expect(result).toContain('Alpha');\n      });\n\n      it('warns and strips limit when it is not a positive integer', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nlimit: -1\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('limit');\n        expect(result).toContain('Alpha');\n        expect(result).toContain('Beta');\n      });\n\n      it('warns and strips format when it is not a valid value', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nformat: bad\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('format');\n        expect(result).toContain('Alpha');\n      });\n    });\n\n    describe('properties dot notation in select', () => {\n      it('renders a property value as a table column', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [title, properties.status]\\nformat: table\\n```'\n        );\n        expect(result).toContain('<th>properties.status</th>');\n        expect(result).toContain('published'); // noteD has status: published\n      });\n\n      it('renders empty cell for notes missing the property', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [title, properties.status]\\nformat: table\\n```'\n        );\n        // Alpha and Beta have no status property — their cells should be empty\n        expect(result).toContain('<td></td>');\n        // No warning for missing property on a note\n        expect(result).not.toContain('foam-query-warning');\n      });\n\n      it('renders a property value in list format and skips notes without that property', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [properties.status]\\n```'\n        );\n        expect(result).toContain('published'); // noteD has status\n        expect(result).not.toContain('<li></li>'); // no empty bullets for notes missing the property\n      });\n\n      it('accepts properties.X fields without warning', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [title, properties.anything]\\nformat: table\\n```'\n        );\n        expect(result).not.toContain('foam-query-warning');\n      });\n    });\n\n    describe('unknown fields in select', () => {\n      it('warns and strips unknown select elements, keeps valid ones', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [title, unknown_field]\\nformat: table\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('unknown_field');\n        expect(result).toContain('Alpha'); // valid field still renders\n        expect(result).not.toContain('<th>unknown_field</th>');\n      });\n\n      it('falls back to default select when all elements are unknown', () => {\n        const result = md.render(\n          '```foam-query\\nfilter: \"#research\"\\nselect: [bad1, bad2]\\n```'\n        );\n        expect(result).toContain('foam-query-warning');\n        expect(result).toContain('Alpha'); // falls back to default (title)\n      });\n    });\n\n    it('title cells in tables render as foam-note-link anchors', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#other\"\\nformat: table\\nselect: [title, type]\\n```'\n      );\n      expect(result).toContain('class=\"foam-note-link\"');\n      expect(result).toContain('/notes/gamma.md');\n    });\n\n    it('non-title cells render as plain text, not links', () => {\n      const result = md.render(\n        '```foam-query\\nfilter: \"#other\"\\nformat: table\\nselect: [title, type]\\n```'\n      );\n      // 'type' column value should be plain text, not an anchor\n      const typeCell = result.match(/<td>([^<]+)<\\/td>/)?.[1];\n      expect(typeCell).toBeDefined();\n      expect(typeCell).not.toContain('<a');\n    });\n  });\n\n  describe('foam-query-js — JS blocks', () => {\n    it('renders output from render() calls', () => {\n      const result = md.render(\n        \"```foam-query-js\\nrender(foam.pages('#research').format('count'));\\n```\"\n      );\n      expect(result).toContain('3 notes');\n    });\n\n    it('supports calling render() with a plain string', () => {\n      const result = md.render('```foam-query-js\\nrender(\"hello world\");\\n```');\n      expect(result).toContain('hello world');\n    });\n\n    it('supports multi-line scripts with logic', () => {\n      const result = md.render(\n        [\n          '```foam-query-js',\n          \"const all = foam.pages('#research').sortBy('title').limit(1);\",\n          \"render(all.format('list'));\",\n          '```',\n        ].join('\\n')\n      );\n      expect(result).toContain('Alpha');\n      expect(result).not.toContain('Beta');\n    });\n\n    it('supports multiple render() calls which append output', () => {\n      const result = md.render(\n        [\n          '```foam-query-js',\n          'render(\"first\");',\n          'render(\"second\");',\n          '```',\n        ].join('\\n')\n      );\n      expect(result).toContain('first');\n      expect(result).toContain('second');\n    });\n\n    it('shows a message when render() is never called', () => {\n      const result = md.render('```foam-query-js\\nconst x = 1;\\n```');\n      expect(result).toContain('foam-query-empty');\n    });\n\n    it('renders a script runtime error gracefully', () => {\n      const result = md.render(\n        '```foam-query-js\\nthrow new Error(\"boom\");\\n```'\n      );\n      expect(result).toContain('foam-query-error');\n      expect(result).toContain('boom');\n    });\n\n    it('blocked globals are not accessible', () => {\n      const result = md.render(\n        '```foam-query-js\\nrender(typeof require);\\n```'\n      );\n      // require should be undefined in the sandbox\n      expect(result).toContain('undefined');\n    });\n\n    it('title cells in JS table render as foam-note-link even when path is not selected', () => {\n      const result = md.render(\n        [\n          '```foam-query-js',\n          \"render(foam.pages('#other').select(['title','type']).format('table'));\",\n          '```',\n        ].join('\\n')\n      );\n      expect(result).toContain('class=\"foam-note-link\"');\n      expect(result).toContain('/notes/gamma.md');\n      // path should not appear as a table column\n      expect(result).not.toContain('<th>path</th>');\n    });\n\n    it('shows untrusted message when isTrusted returns false', () => {\n      const untrustedMd = markdownItFoamQuery(MarkdownIt(), ws, graph, {\n        isTrusted: () => false,\n        toRelativePath,\n      });\n      const result = untrustedMd.render(\n        '```foam-query-js\\nrender(\"hello\");\\n```'\n      );\n      expect(result).toContain('foam-query-untrusted');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/foam-query-renderer.ts",
    "content": "/*global markdownit:readonly*/\n\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { FoamGraph } from '../../core/model/graph';\nimport { Logger } from '../../core/utils/log';\nimport { renderDqlQuery } from '../../core/query/dql';\nimport { renderJsQuery } from '../../core/query/js';\nimport { escapeHtml } from '../../core/query/html';\n\nexport function markdownItFoamQuery(\n  md: markdownit,\n  workspace: FoamWorkspace,\n  graph: FoamGraph,\n  options: {\n    isTrusted: () => boolean;\n    toRelativePath: (path: string) => string;\n  }\n): markdownit {\n  const { isTrusted, toRelativePath } = options;\n\n  const defaultFence: any =\n    md.renderer.rules.fence ??\n    ((tokens: any, idx: any, options: any, _env: any, self: any) =>\n      self.renderToken(tokens, idx, options));\n\n  md.renderer.rules.fence = (tokens, idx, options, env, self) => {\n    const token = tokens[idx];\n    const info = token.info.trim();\n\n    if (info !== 'foam-query' && info !== 'foam-query-js') {\n      return defaultFence(tokens, idx, options, env, self);\n    }\n\n    try {\n      return info === 'foam-query'\n        ? renderDqlQuery(\n            token.content,\n            workspace,\n            graph,\n            isTrusted(),\n            toRelativePath\n          )\n        : renderJsQuery(\n            token.content,\n            workspace,\n            graph,\n            isTrusted(),\n            toRelativePath\n          );\n    } catch (e) {\n      Logger.error(`[foam-query] error rendering ${info} block`, e);\n      return `<div class=\"foam-query-error\">Query error: ${escapeHtml(\n        String(e)\n      )}</div>`;\n    }\n  };\n\n  return md;\n}\n\nexport default markdownItFoamQuery;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/index.ts",
    "content": "/*global markdownit:readonly*/\n\nimport * as vscode from 'vscode';\nimport { Foam } from '../../core/model/foam';\nimport { default as markdownItFoamTags } from './tag-highlight';\nimport { default as markdownItWikilinkNavigation } from './wikilink-navigation';\nimport { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';\nimport { default as markdownItWikilinkEmbed } from './wikilink-embed';\nimport { default as escapeWikilinkPipes } from './escape-wikilink-pipes';\nimport { default as markdownItBlockAnchorIds } from './block-anchor-ids';\nimport { default as markdownItFoamQuery } from './foam-query-renderer';\nimport { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';\nimport { URI } from '../../core/model/uri';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  // Refresh the markdown preview whenever the workspace changes so that\n  // foam-query embed blocks show up-to-date results in real time.\n  context.subscriptions.push(\n    foam.workspace.onDidAdd(() =>\n      vscode.commands.executeCommand('markdown.preview.refresh')\n    ),\n    foam.workspace.onDidUpdate(() =>\n      vscode.commands.executeCommand('markdown.preview.refresh')\n    ),\n    foam.workspace.onDidDelete(() =>\n      vscode.commands.executeCommand('markdown.preview.refresh')\n    )\n  );\n\n  return {\n    extendMarkdownIt: (md: markdownit) => {\n      const ws = foam.workspace;\n      const graph = foam.graph;\n      const parser = foam.services.parser;\n\n      // Used to resolve self-referencing embeds (![[#section]], ![[#^blockid]]).\n      // activeTextEditor is the best available proxy for the document being previewed.\n      const getCurrentResource = () => {\n        const editor = vscode.window.activeTextEditor;\n        return editor ? ws.find(fromVsCodeUri(editor.document.uri)) : null;\n      };\n      let result = escapeWikilinkPipes(md);\n      result = markdownItWikilinkEmbed(result, ws, parser, getCurrentResource);\n      result = markdownItFoamTags(result, ws);\n      result = markdownItWikilinkNavigation(result, ws);\n      result = markdownItRemoveLinkReferences(result, ws);\n      result = markdownItBlockAnchorIds(result);\n      result = markdownItFoamQuery(result, ws, graph, {\n        isTrusted: () => vscode.workspace.isTrusted,\n        toRelativePath: (uriPath: string) =>\n          vscode.workspace.asRelativePath(\n            toVsCodeUri(URI.file(uriPath)),\n            false\n          ),\n      });\n      return result;\n    },\n  };\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/remove-wikilink-references.ts",
    "content": "/*global markdownit:readonly*/\n\nimport { FoamWorkspace } from '../../core/model/workspace';\n\nexport const markdownItRemoveLinkReferences = (\n  md: markdownit,\n  workspace: FoamWorkspace\n) => {\n  md.inline.ruler.before('link', 'clear-references', state => {\n    if (state.env.references) {\n      const src = state.src.toLowerCase();\n      const foamLinkRegEx = /\\[\\[([^[\\]]+?)\\]\\]/g;\n      const foamLinks = [...src.matchAll(foamLinkRegEx)].map(m =>\n        m[1].toLowerCase()\n      );\n\n      Object.keys(state.env.references).forEach(refKey => {\n        // Remove all references that have corresponding wikilinks.\n        // If the markdown parser sees a reference, it will format it before\n        // we get a chance to create the wikilink.\n        if (foamLinks.includes(refKey.toLowerCase())) {\n          delete state.env.references[refKey];\n        }\n      });\n    }\n    return false;\n  });\n  return md;\n};\n\nexport default markdownItRemoveLinkReferences;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/tag-highlight.spec.ts",
    "content": "/* @unit-ready */\nimport MarkdownIt from 'markdown-it';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { default as markdownItFoamTags } from './tag-highlight';\n\ndescribe('Stylable tag generation in preview', () => {\n  const md = markdownItFoamTags(MarkdownIt(), new FoamWorkspace());\n\n  it('transforms a string containing multiple tags to a stylable html element', () => {\n    expect(md.render(`Lorem #ipsum dolor #sit`)).toMatch(\n      `<p>Lorem <span class='foam-tag'>#ipsum</span> dolor <span class='foam-tag'>#sit</span></p>`\n    );\n  });\n\n  it('transforms a string containing a tag with dash', () => {\n    expect(md.render(`Lorem ipsum dolor #si-t`)).toMatch(\n      `<p>Lorem ipsum dolor <span class='foam-tag'>#si-t</span></p>`\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/tag-highlight.ts",
    "content": "/*global markdownit:readonly*/\n\nimport markdownItRegex from 'markdown-it-regex';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { Logger } from '../../core/utils/log';\n\nexport const markdownItFoamTags = (\n  md: markdownit,\n  workspace: FoamWorkspace\n) => {\n  return md.use(markdownItRegex, {\n    name: 'foam-tags',\n    regex: /(?<=^|\\s)(#[0-9]*[\\p{L}/_-][\\p{L}\\p{N}/_-]*)/u,\n    replace: (tag: string) => {\n      try {\n        return getFoamTag(tag);\n      } catch (e) {\n        Logger.error(\n          `Error while creating link for ${tag} in Preview panel`,\n          e\n        );\n        return getFoamTag(tag);\n      }\n    },\n  });\n};\n\n// Commands can't be run in the preview (see https://github.com/microsoft/vscode/issues/102532)\n// for we just return the tag as a span\nconst getFoamTag = (content: string) =>\n  `<span class='foam-tag'>${content}</span>`;\n\nexport default markdownItFoamTags;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-embed-web-extension.ts",
    "content": "/*global markdownit:readonly*/\n\nimport markdownItRegex from 'markdown-it-regex';\nimport { ResourceParser } from '../../core/model/note';\nimport { FoamWorkspace } from '../../core/model/workspace';\n\nexport const WIKILINK_EMBED_REGEX =\n  /((?:(?:full|content)-(?:inline|card)|full|content|inline|card)?!\\[\\[[^[\\]]+?\\]\\])/;\n\nexport const markdownItWikilinkEmbed = (\n  md: markdownit,\n  workspace: FoamWorkspace,\n  parser: ResourceParser\n) => {\n  return md.use(markdownItRegex, {\n    name: 'embed-wikilinks',\n    regex: WIKILINK_EMBED_REGEX,\n    replace: (wikilinkItem: string) => {\n      return `\n      <div class=\"foam-embed-not-supported-warning\">\n        Embed not supported in web mode: ${wikilinkItem}\n      </div>\n`;\n    },\n  });\n};\n\nexport default markdownItWikilinkEmbed;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-embed.spec.ts",
    "content": "/* @unit-ready */\nimport MarkdownIt from 'markdown-it';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { createMarkdownParser } from '../../core/services/markdown-parser';\nimport {\n  createFile,\n  deleteFile,\n  withModifiedFoamConfiguration,\n} from '../../test/test-utils-vscode';\nimport {\n  default as markdownItWikilinkEmbed,\n  CONFIG_EMBED_NOTE_TYPE,\n} from './wikilink-embed';\n\nconst parser = createMarkdownParser();\n\ndescribe('Displaying included notes in preview', () => {\n  it('should render an included note in full inline mode', async () => {\n    const note = await createFile('This is the text of note A', [\n      'preview',\n      'note-a.md',\n    ]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        expect(\n          md.render(`This is the root node. \n  \n   ![[note-a]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><p>This is the text of note A</p>\n</p>`\n        );\n      }\n    );\n    await deleteFile(note);\n  });\n\n  it('should render an included note in full card mode', async () => {\n    const note = await createFile('This is the text of note A', [\n      'preview',\n      'note-a.md',\n    ]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-card',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        const res = md.render(`This is the root node. ![[note-a]]`);\n        expect(res).toContain('This is the root node');\n        expect(res).toContain('embed-container-note');\n        expect(res).toContain('This is the text of note A');\n      }\n    );\n    await deleteFile(note);\n  });\n\n  it('should render an included section in full inline mode', async () => {\n    // here we use createFile as the test note doesn't fill in\n    // all the metadata we need\n    const note = await createFile(\n      `\n# Section 1\nThis is the first section of note E\n\n# Section 2 \nThis is the second section of note E\n\n# Section 3\nThis is the third section of note E\n    `,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n    const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        expect(\n          md.render(`This is the root node. \n\n ![[note-e#Section 2]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><h1>Section 2</h1>\n<p>This is the second section of note E</p>\n</p>`\n        );\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should render an included section in full card mode', async () => {\n    const note = await createFile(\n      `\n# Section 1\nThis is the first section of note E\n\n# Section 2 \nThis is the second section of note E\n\n# Section 3\nThis is the third section of note E\n    `,\n      ['note-e-container.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-card',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        const res = md.render(\n          `This is the root node. ![[note-e-container#Section 3]]`\n        );\n        expect(res).toContain('This is the root node');\n        expect(res).toContain('embed-container-note');\n        expect(res).toContain('Section 3');\n        expect(res).toContain('This is the third section of note E');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should not render the title of a note in content inline mode', async () => {\n    const note = await createFile(\n      `\n# Title\n## Section 1\n\nThis is the first section of note E`,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'content-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        expect(\n          md.render(`This is the root node. \n            \n![[note-e]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><h2>Section 1</h2>\n<p>This is the first section of note E</p>\n</p>`\n        );\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should not render the title of a note in content card mode', async () => {\n    const note = await createFile(\n      `# Title\n## Section 1\n\nThis is the first section of note E\n      `,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'content-card',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        const res = md.render(`This is the root node. ![[note-e.md]]`);\n\n        expect(res).toContain('This is the root node');\n        expect(res).toContain('embed-container-note');\n        expect(res).toContain('Section 1');\n        expect(res).toContain('This is the first section of note E');\n        expect(res).not.toContain('Title');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should not render the section title, but still render subsection titles in content inline mode', async () => {\n    const note = await createFile(\n      `# Title\n\n\n## Section 1\nThis is the first section of note E\n\n### Subsection a\nThis is the first subsection of note E\n      `,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'content-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        expect(\n          md.render(`This is the root node. \n              \n![[note-e#Section 1]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><p>This is the first section of note E</p>\n<h3>Subsection a</h3>\n<p>This is the first subsection of note E</p>\n</p>`\n        );\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should not render the subsection title in content mode if you link to it and regardless of its level', async () => {\n    const note = await createFile(\n      `# Title\n## Section 1\nThis is the first section of note E\n\n### Subsection a\nThis is the first subsection of note E`,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'content-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n        expect(\n          md.render(`This is the root node. \n\n![[note-e#Subsection a]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><p>This is the first subsection of note E</p>\n</p>`\n        );\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should allow a note embedding type to be overridden if a modifier is passed in', async () => {\n    const note = await createFile(\n      `\n# Section 1\nThis is the first section of note E\n\n# Section 2 \nThis is the second section of note E\n\n# Section 3\nThis is the third section of note E\n    `,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n    const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        expect(\n          md.render(`This is the root node. \n\n content![[note-e#Section 2]]\n \n full![[note-e#Section 3]]`)\n        ).toMatch(\n          `<p>This is the root node.</p>\n<p><p>This is the second section of note E</p>\n</p>\n<p><h1>Section 3</h1>\n<p>This is the third section of note E</p>\n</p>\n`\n        );\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should allow a note embedding type to be overridden if two modifiers are passed in', async () => {\n    const note = await createFile(\n      `\n# Section 1\nThis is the first section of note E\n\n# Section 2 \nThis is the second section of note E\n    `,\n      ['note-e.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n    const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const res = md.render(`This is the root node. \n \ncontent-card![[note-e#Section 2]]`);\n\n        expect(res).toContain('This is the root node');\n        expect(res).toContain('embed-container-note');\n        expect(res).toContain('This is the second section of note E');\n        expect(res).not.toContain('Section 2');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should fallback to the bare text when the note is not found', () => {\n    const md = markdownItWikilinkEmbed(\n      MarkdownIt(),\n      new FoamWorkspace(),\n      parser\n    );\n\n    expect(md.render(`This is the root node. ![[non-existing-note]]`)).toMatch(\n      `<p>This is the root node. ![[non-existing-note]]</p>`\n    );\n  });\n\n  it('should render the bare text for an embedded note that is embedding a note that is not found', async () => {\n    const note = await createFile(\n      'This is the text of note A which includes ![[does-not-exist]]',\n      ['note.md']\n    );\n\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n        expect(md.render(`This is the root node. ![[note]]`)).toMatch(\n          `<p>This is the root node. <p>This is the text of note A which includes ![[does-not-exist]]</p>\n</p>`\n        );\n      }\n    );\n  });\n\n  it('should display a warning in case of cyclical inclusions', async () => {\n    const noteA = await createFile(\n      'This is the text of note A which includes ![[note-b]]',\n      ['preview', 'note-a.md']\n    );\n\n    const noteBText = 'This is the text of note B which includes ![[note-a]]';\n    const noteB = await createFile(noteBText, ['preview', 'note-b.md']);\n\n    const ws = new FoamWorkspace()\n      .set(parser.parse(noteA.uri, noteA.content))\n      .set(parser.parse(noteB.uri, noteB.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-card',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n        const res = md.render(noteBText);\n\n        expect(res).toContain('This is the text of note B which includes');\n        expect(res).toContain('This is the text of note A which includes');\n        expect(res).toContain('Cyclic link detected for wikilink');\n      }\n    );\n\n    await deleteFile(noteA);\n    await deleteFile(noteB);\n  });\n\n  it('should render only the block content when embedding a block anchor in full inline mode', async () => {\n    const note = await createFile(\n      `First paragraph\n\nSecond paragraph ^target-block\n\nThird paragraph`,\n      ['note-with-block.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n        const res = md.render(`![[note-with-block#^target-block]]`);\n        expect(res).toContain('Second paragraph');\n        expect(res).not.toContain('First paragraph');\n        expect(res).not.toContain('Third paragraph');\n        // Block anchor marker should be stripped\n        expect(res).not.toContain('^target-block');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should render only the block content when embedding a block anchor in content inline mode', async () => {\n    const note = await createFile(\n      `First paragraph\n\nSecond paragraph ^target-block\n\nThird paragraph`,\n      ['note-with-block-content.md']\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'content-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);\n        const res = md.render(`![[note-with-block-content#^target-block]]`);\n        expect(res).toContain('Second paragraph');\n        expect(res).not.toContain('First paragraph');\n        expect(res).not.toContain('Third paragraph');\n        expect(res).not.toContain('^target-block');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should embed a section from the current note using a self-referencing link', async () => {\n    const note = await createFile(\n      `# Section 1\nContent of section one.\n\n# Section 2\nContent of section two.`,\n      ['self-ref-section.md']\n    );\n    const parser = createMarkdownParser([]);\n    const parsedNote = parser.parse(note.uri, note.content);\n    const ws = new FoamWorkspace().set(parsedNote);\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(\n          MarkdownIt(),\n          ws,\n          parser,\n          () => parsedNote\n        );\n        const res = md.render(`Intro. ![[#Section 2]]`);\n        expect(res).toContain('Content of section two');\n        expect(res).not.toContain('Content of section one');\n      }\n    );\n\n    await deleteFile(note);\n  });\n\n  it('should embed a block from the current note using a self-referencing block anchor link', async () => {\n    const note = await createFile(\n      `First paragraph\n\nTarget block ^self-block\n\nThird paragraph`,\n      ['self-ref-block.md']\n    );\n    const parser = createMarkdownParser([]);\n    const parsedNote = parser.parse(note.uri, note.content);\n    const ws = new FoamWorkspace().set(parsedNote);\n\n    await withModifiedFoamConfiguration(\n      CONFIG_EMBED_NOTE_TYPE,\n      'full-inline',\n      () => {\n        const md = markdownItWikilinkEmbed(\n          MarkdownIt(),\n          ws,\n          parser,\n          () => parsedNote\n        );\n        const res = md.render(`Intro. ![[#^self-block]]`);\n        expect(res).toContain('Target block');\n        expect(res).not.toContain('First paragraph');\n        expect(res).not.toContain('Third paragraph');\n        expect(res).not.toContain('^self-block');\n      }\n    );\n\n    await deleteFile(note);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-embed.test.ts",
    "content": "import {\n  WIKILINK_EMBED_REGEX,\n  WIKILINK_EMBED_REGEX_GROUPS,\n  retrieveNoteConfig,\n  parseImageParameters,\n  generateImageStyles,\n  extractBlockContent,\n} from './wikilink-embed';\nimport * as config from '../../services/config';\nimport { createMarkdownParser } from '../../core/services/markdown-parser';\nimport { getRandomURI } from '../../test/test-utils';\n\ndescribe('Wikilink Note Embedding', () => {\n  afterEach(() => {\n    jest.clearAllMocks();\n  });\n\n  describe('Wikilink Parsing', () => {\n    it('should match a wikilink item including a modifier and wikilink', () => {\n      // no configuration\n      expect('![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n\n      // one of the configurations\n      expect('full![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('content![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('inline![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('card![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n\n      // any combination of configurations\n      expect('full-inline![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('full-card![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('content-inline![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n      expect('content-card![[note-a]]').toMatch(WIKILINK_EMBED_REGEX);\n    });\n\n    it('should only match the wikilink if there are unrecognized keywords', () => {\n      const match1 = 'random-word![[note-a]]'.match(WIKILINK_EMBED_REGEX);\n      expect(match1[0]).toEqual('![[note-a]]');\n      expect(match1[1]).toEqual('![[note-a]]');\n\n      const match2 = 'foo![[note-a#section 1]]'.match(WIKILINK_EMBED_REGEX);\n      expect(match2[0]).toEqual('![[note-a#section 1]]');\n      expect(match2[1]).toEqual('![[note-a#section 1]]');\n    });\n\n    it('should group the wikilink into modifier and wikilink', () => {\n      const match1 = 'content![[note-a]]'.match(WIKILINK_EMBED_REGEX_GROUPS);\n      expect(match1[0]).toEqual('content![[note-a]]');\n      expect(match1[1]).toEqual('content');\n      expect(match1[2]).toEqual('note-a');\n\n      const match2 = 'full-inline![[note-a#section 1]]'.match(\n        WIKILINK_EMBED_REGEX_GROUPS\n      );\n      expect(match2[0]).toEqual('full-inline![[note-a#section 1]]');\n      expect(match2[1]).toEqual('full-inline');\n      expect(match2[2]).toEqual('note-a#section 1');\n\n      const match3 = '![[note-a#section 1]]'.match(WIKILINK_EMBED_REGEX_GROUPS);\n      expect(match3[0]).toEqual('![[note-a#section 1]]');\n      expect(match3[1]).toEqual(undefined);\n      expect(match3[2]).toEqual('note-a#section 1');\n    });\n  });\n\n  describe('Image Parameter Parsing', () => {\n    it('should parse wikilinks with image sizing parameters', () => {\n      // Width only\n      const match1 = '![[image.png|300]]'.match(WIKILINK_EMBED_REGEX_GROUPS);\n      expect(match1[0]).toEqual('![[image.png|300]]');\n      expect(match1[1]).toEqual(undefined); // no modifier\n      expect(match1[2]).toEqual('image.png');\n      expect(match1[3]).toEqual('|300');\n\n      // Width and height\n      const match2 = '![[image.png|300x200]]'.match(\n        WIKILINK_EMBED_REGEX_GROUPS\n      );\n      expect(match2[0]).toEqual('![[image.png|300x200]]');\n      expect(match2[1]).toEqual(undefined);\n      expect(match2[2]).toEqual('image.png');\n      expect(match2[3]).toEqual('|300x200');\n\n      // Percentage width\n      const match3 = '![[image.png|50%]]'.match(WIKILINK_EMBED_REGEX_GROUPS);\n      expect(match3[0]).toEqual('![[image.png|50%]]');\n      expect(match3[1]).toEqual(undefined);\n      expect(match3[2]).toEqual('image.png');\n      expect(match3[3]).toEqual('|50%');\n    });\n\n    it('should parse wikilinks with modifiers and image parameters', () => {\n      const match = 'content![[image.png|300]]'.match(\n        WIKILINK_EMBED_REGEX_GROUPS\n      );\n      expect(match[0]).toEqual('content![[image.png|300]]');\n      expect(match[1]).toEqual('content');\n      expect(match[2]).toEqual('image.png');\n      expect(match[3]).toEqual('|300');\n    });\n\n    it('should parse wikilinks with multiple parameters', () => {\n      const match = '![[image.png|300|center]]'.match(\n        WIKILINK_EMBED_REGEX_GROUPS\n      );\n      expect(match[0]).toEqual('![[image.png|300|center]]');\n      expect(match[1]).toEqual(undefined);\n      expect(match[2]).toEqual('image.png');\n      expect(match[3]).toEqual('|300|center');\n    });\n\n    it('should handle wikilinks without parameters (backward compatibility)', () => {\n      const match = '![[image.png]]'.match(WIKILINK_EMBED_REGEX_GROUPS);\n      expect(match[0]).toEqual('![[image.png]]');\n      expect(match[1]).toEqual(undefined);\n      expect(match[2]).toEqual('image.png');\n      expect(match[3]).toEqual(undefined);\n    });\n\n    it('should parse complex filenames with parameters', () => {\n      const match = '![[folder/image-file.png|400px]]'.match(\n        WIKILINK_EMBED_REGEX_GROUPS\n      );\n      expect(match[0]).toEqual('![[folder/image-file.png|400px]]');\n      expect(match[1]).toEqual(undefined);\n      expect(match[2]).toEqual('folder/image-file.png');\n      expect(match[3]).toEqual('|400px');\n    });\n  });\n\n  describe('parseImageParameters Function', () => {\n    it('should parse width-only parameters', () => {\n      const result = parseImageParameters('image.png', '|300');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n      });\n    });\n\n    it('should parse width x height parameters', () => {\n      const result = parseImageParameters('image.png', '|300x200');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        height: '200',\n      });\n    });\n\n    it('should parse percentage widths', () => {\n      const result = parseImageParameters('image.png', '|50%');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '50%',\n      });\n    });\n\n    it('should parse width with units', () => {\n      const result = parseImageParameters('image.png', '|400px');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '400px',\n      });\n    });\n\n    it('should parse width and alignment', () => {\n      const result = parseImageParameters('image.png', '|300|center');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        align: 'center',\n      });\n    });\n\n    it('should parse width, alignment, and alt text', () => {\n      const result = parseImageParameters(\n        'image.png',\n        '|300|left|My image description'\n      );\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        align: 'left',\n        alt: 'My image description',\n      });\n    });\n\n    it('should parse width and alt text (no alignment)', () => {\n      const result = parseImageParameters(\n        'image.png',\n        '|300|My image description'\n      );\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        alt: 'My image description',\n      });\n    });\n\n    it('should handle no parameters', () => {\n      const result = parseImageParameters('image.png');\n      expect(result).toEqual({\n        filename: 'image.png',\n      });\n    });\n\n    it('should handle empty parameters string', () => {\n      const result = parseImageParameters('image.png', '');\n      expect(result).toEqual({\n        filename: 'image.png',\n      });\n    });\n\n    it('should handle malformed parameters gracefully', () => {\n      const result = parseImageParameters('image.png', '|');\n      expect(result).toEqual({\n        filename: 'image.png',\n      });\n    });\n\n    it('should parse complex width x height with units', () => {\n      const result = parseImageParameters('image.png', '|400px x 300px');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '400px',\n        height: '300px',\n      });\n    });\n\n    it('should handle right alignment', () => {\n      const result = parseImageParameters('image.png', '|300|right');\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        align: 'right',\n      });\n    });\n\n    it('should handle alt text with pipes', () => {\n      const result = parseImageParameters(\n        'image.png',\n        '|300|center|Alt text with | pipes'\n      );\n      expect(result).toEqual({\n        filename: 'image.png',\n        width: '300',\n        align: 'center',\n        alt: 'Alt text with | pipes',\n      });\n    });\n  });\n\n  describe('generateImageStyles Function', () => {\n    const mockMd = {\n      normalizeLink: (path: string) => path,\n    } as any;\n\n    it('should generate basic image HTML without parameters', () => {\n      const params = { filename: 'image.png' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual('<img src=\"image.png\" alt=\"\">');\n    });\n\n    it('should generate image with width only', () => {\n      const params = { filename: 'image.png', width: '300' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"\">'\n      );\n    });\n\n    it('should generate image with width and height', () => {\n      const params = { filename: 'image.png', width: '300', height: '200' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 300px; height: 200px\" alt=\"\">'\n      );\n    });\n\n    it('should generate image with percentage width', () => {\n      const params = { filename: 'image.png', width: '50%' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 50%; height: auto\" alt=\"\">'\n      );\n    });\n\n    it('should generate image with width and units preserved', () => {\n      const params = { filename: 'image.png', width: '400px' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 400px; height: auto\" alt=\"\">'\n      );\n    });\n\n    it('should generate image with center alignment', () => {\n      const params = {\n        filename: 'image.png',\n        width: '300',\n        align: 'center' as const,\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<div style=\"text-align: center;\"><img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"\"></div>'\n      );\n    });\n\n    it('should generate image with left alignment', () => {\n      const params = {\n        filename: 'image.png',\n        width: '300',\n        align: 'left' as const,\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<div style=\"text-align: left;\"><img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"\"></div>'\n      );\n    });\n\n    it('should generate image with right alignment', () => {\n      const params = {\n        filename: 'image.png',\n        width: '300',\n        align: 'right' as const,\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<div style=\"text-align: right;\"><img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"\"></div>'\n      );\n    });\n\n    it('should generate image with alt text', () => {\n      const params = {\n        filename: 'image.png',\n        width: '300',\n        alt: 'My image description',\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"My image description\">'\n      );\n    });\n\n    it('should escape HTML in alt text', () => {\n      const params = {\n        filename: 'image.png',\n        alt: 'Image with <script>alert(\"xss\")</script>',\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" alt=\"Image with &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;\">'\n      );\n    });\n\n    it('should generate image with width, alignment, and alt text', () => {\n      const params = {\n        filename: 'image.png',\n        width: '300',\n        align: 'center' as const,\n        alt: 'Centered image',\n      };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<div style=\"text-align: center;\"><img src=\"image.png\" style=\"width: 300px; height: auto\" alt=\"Centered image\"></div>'\n      );\n    });\n\n    it('should handle em units', () => {\n      const params = { filename: 'image.png', width: '20em' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 20em; height: auto\" alt=\"\">'\n      );\n    });\n\n    it('should handle decimal values', () => {\n      const params = { filename: 'image.png', width: '300.5' };\n      const result = generateImageStyles(params, mockMd);\n      expect(result).toEqual(\n        '<img src=\"image.png\" style=\"width: 300.5px; height: auto\" alt=\"\">'\n      );\n    });\n  });\n\n  describe('extractBlockContent', () => {\n    const parser = createMarkdownParser([]);\n\n    it('should return section content for a plain-text heading block', () => {\n      const noteText = `# My Heading ^blockid\\n\\nBody content.\\n`;\n      const note = parser.parse(getRandomURI(), noteText);\n      const block = note.blocks.find(b => b.id === 'blockid');\n      expect(block).toBeDefined();\n      const result = extractBlockContent(noteText, note, block);\n      expect(result).toContain('My Heading');\n      expect(result).toContain('Body content');\n      expect(result).not.toContain('^blockid');\n    });\n\n    it('should return section content for a heading with inline formatting (e.g. **bold**)', () => {\n      // Before the fix, `headingLabel` was derived from raw text as `**Bold Title**`,\n      // which did not match the AST-parsed section label `Bold Title`, causing\n      // Resource.findSection to return null and falling back to the heading line only.\n      const noteText = `## **Bold Title** ^blockid\\n\\nBody content.\\n`;\n      const note = parser.parse(getRandomURI(), noteText);\n      const block = note.blocks.find(b => b.id === 'blockid');\n      expect(block).toBeDefined();\n      const result = extractBlockContent(noteText, note, block);\n      expect(result).toContain('**Bold Title**');\n      expect(result).toContain('Body content');\n      expect(result).not.toContain('^blockid');\n    });\n\n    it('should return only the heading line when the section is not found', () => {\n      // Block type heading but no matching section (degenerate case)\n      const noteText = `# Standalone Heading ^blockid\\n`;\n      const note = parser.parse(getRandomURI(), noteText);\n      const block = note.blocks.find(b => b.id === 'blockid');\n      expect(block).toBeDefined();\n      const result = extractBlockContent(noteText, note, block);\n      expect(result).toContain('Standalone Heading');\n      expect(result).not.toContain('^blockid');\n    });\n\n    it('should strip the ^id marker for a paragraph block', () => {\n      const noteText = `A simple paragraph. ^blockid\\n`;\n      const note = parser.parse(getRandomURI(), noteText);\n      const block = note.blocks.find(b => b.id === 'blockid');\n      expect(block).toBeDefined();\n      const result = extractBlockContent(noteText, note, block);\n      expect(result).toContain('A simple paragraph.');\n      expect(result).not.toContain('^blockid');\n    });\n  });\n\n  describe('Config Parsing', () => {\n    it('should use preview.embedNoteType if an explicit modifier is not passed in', () => {\n      jest\n        .spyOn(config, 'getFoamVsCodeConfig')\n        .mockReturnValueOnce('full-card');\n\n      const { noteScope, noteStyle } = retrieveNoteConfig(undefined);\n      expect(noteScope).toEqual('full');\n      expect(noteStyle).toEqual('card');\n    });\n\n    it('should use explicit modifier over user settings if passed in', () => {\n      jest\n        .spyOn(config, 'getFoamVsCodeConfig')\n        .mockReturnValueOnce('full-inline')\n        .mockReturnValueOnce('full-inline')\n        .mockReturnValueOnce('full-inline');\n\n      let { noteScope, noteStyle } = retrieveNoteConfig('content-card');\n      expect(noteScope).toEqual('content');\n      expect(noteStyle).toEqual('card');\n\n      ({ noteScope, noteStyle } = retrieveNoteConfig('content'));\n      expect(noteScope).toEqual('content');\n      expect(noteStyle).toEqual('inline');\n\n      ({ noteScope, noteStyle } = retrieveNoteConfig('card'));\n      expect(noteScope).toEqual('full');\n      expect(noteStyle).toEqual('card');\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-embed.ts",
    "content": "/*global markdownit:readonly*/\n\n// eslint-disable-next-line no-restricted-imports\nimport { readFileSync } from 'fs';\nimport { workspace as vsWorkspace } from 'vscode';\nimport markdownItRegex from 'markdown-it-regex';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { Logger } from '../../core/utils/log';\nimport { Resource, ResourceParser, Block } from '../../core/model/note';\nimport { getFoamVsCodeConfig } from '../../services/config';\nimport { fromVsCodeUri } from '../../utils/vsc-utils';\nimport { MarkdownLink } from '../../core/services/markdown-link';\nimport { URI } from '../../core/model/uri';\nimport { Position } from '../../core/model/position';\nimport { TextEdit } from '../../core/services/text-edit';\nimport { isNone, isSome } from '../../core/utils';\nimport {\n  asAbsoluteWorkspaceUri,\n  isVirtualWorkspace,\n} from '../../services/editor';\n\nexport const WIKILINK_EMBED_REGEX =\n  /((?:(?:full|content)-(?:inline|card)|full|content|inline|card)?!\\[\\[[^[\\]]+?\\]\\])/;\n// we need another regex because md.use(regex, replace) only permits capturing one group\n// so we capture the entire possible wikilink item (ex. content-card![[note]]) using WIKILINK_EMBED_REGEX and then\n// use WIKILINK_EMBED_REGEX_GROUPER to parse it into the modifier(content-card) and the wikilink(note)\nexport const WIKILINK_EMBED_REGEX_GROUPS =\n  /((?:\\w+)|(?:(?:\\w+)-(?:\\w+)))?!\\[\\[([^|[\\]]+?)(\\|[^[\\]]+?)?\\]\\]/;\nexport const CONFIG_EMBED_NOTE_TYPE = 'preview.embedNoteType';\nlet refsStack: string[] = [];\n\nexport const markdownItWikilinkEmbed = (\n  md: markdownit,\n  workspace: FoamWorkspace,\n  parser: ResourceParser,\n  getCurrentResource?: () => Resource | null\n) => {\n  return md.use(markdownItRegex, {\n    name: 'embed-wikilinks',\n    regex: WIKILINK_EMBED_REGEX,\n    replace: (wikilinkItem: string) => {\n      try {\n        const [, noteEmbedModifier, wikilink, parametersString] =\n          wikilinkItem.match(WIKILINK_EMBED_REGEX_GROUPS);\n\n        if (isVirtualWorkspace()) {\n          return `\n<div class=\"foam-embed-not-supported-warning\">\n  Embed not supported in virtual workspace: ![[${wikilink}]]\n</div>\n          `;\n        }\n\n        let includedNote = workspace.find(wikilink);\n\n        // Self-referencing embed (![[#section]] or ![[#^blockid]]): the path is\n        // empty so workspace.find returns null. Resolve against the current resource.\n        if (!includedNote && wikilink.startsWith('#') && getCurrentResource) {\n          const currentResource = getCurrentResource();\n          if (currentResource) {\n            const fragment = wikilink.slice(1); // strip leading '#'\n            includedNote = {\n              ...currentResource,\n              uri: currentResource.uri.with({ fragment }),\n            };\n          }\n        }\n\n        if (!includedNote) {\n          return `![[${wikilink}]]`;\n        }\n\n        const cyclicLinkDetected = refsStack.includes(\n          includedNote.uri.path.toLocaleLowerCase()\n        );\n\n        if (cyclicLinkDetected) {\n          return `\n<div class=\"foam-cyclic-link-warning\">\n  Cyclic link detected for wikilink: ${wikilink}\n  <div class=\"foam-cyclic-link-warning__stack\">\n    Link sequence: \n    <ul>\n      ${refsStack.map(ref => `<li>${ref}</li>`).join('')}\n    </ul>\n  </div>\n</div>\n          `;\n        }\n\n        refsStack.push(includedNote.uri.path.toLocaleLowerCase());\n\n        let content: string;\n        try {\n          content = getNoteContent(\n            includedNote,\n            noteEmbedModifier,\n            parser,\n            workspace,\n            md,\n            parametersString\n          );\n        } catch (e) {\n          Logger.error(\n            `Error while including ${wikilinkItem} into the current document of the Preview panel`,\n            e\n          );\n          refsStack.pop();\n          return '';\n        }\n        // Render while the current note is still on the stack so that any\n        // embed patterns inside the content can detect cycles back to it.\n        try {\n          return md.render(content);\n        } finally {\n          refsStack.pop();\n        }\n      } catch (e) {\n        Logger.error(\n          `Error while including ${wikilinkItem} into the current document of the Preview panel`,\n          e\n        );\n        return '';\n      }\n    },\n  });\n};\n\nfunction getNoteContent(\n  includedNote: Resource,\n  noteEmbedModifier: string | undefined,\n  parser: ResourceParser,\n  workspace: FoamWorkspace,\n  md: markdownit,\n  parametersString?: string\n): string {\n  let content = `Embed for [[${includedNote.uri.path}]]`;\n  let toRender: string;\n\n  switch (includedNote.type) {\n    case 'note': {\n      const { noteScope, noteStyle } = retrieveNoteConfig(noteEmbedModifier);\n\n      const extractor: EmbedNoteExtractor =\n        noteScope === 'full'\n          ? fullExtractor\n          : noteScope === 'content'\n          ? contentExtractor\n          : fullExtractor;\n\n      const formatter: EmbedNoteFormatter =\n        noteStyle === 'card'\n          ? cardFormatter\n          : noteStyle === 'inline'\n          ? inlineFormatter\n          : cardFormatter;\n\n      content = extractor(includedNote, parser, workspace);\n      toRender = formatter(content, md);\n      break;\n    }\n    case 'attachment':\n      content = `\n<div class=\"embed-container-attachment\">\n${md.renderInline('[[' + includedNote.uri.path + ']]')}<br/>\nEmbed for attachments is not supported\n</div>`;\n      toRender = md.render(content);\n      break;\n    case 'image': {\n      const imageParams = parseImageParameters(\n        includedNote.uri.path,\n        parametersString\n      );\n      const imageHtml = generateImageStyles(imageParams, md);\n      content = `<div class=\"embed-container-image\">${imageHtml}</div>`;\n      toRender = content;\n      break;\n    }\n    default:\n      toRender = content;\n  }\n\n  return toRender;\n}\n\nfunction withLinksRelativeToWorkspaceRoot(\n  noteUri: URI,\n  noteText: string,\n  parser: ResourceParser,\n  workspace: FoamWorkspace\n): string {\n  const note = parser.parse(\n    fromVsCodeUri(vsWorkspace.workspaceFolders[0].uri),\n    noteText\n  );\n  const edits = note.links\n    .map(link => {\n      const info = MarkdownLink.analyzeLink(link);\n      const resource = workspace.find(info.target, noteUri);\n      // embedded notes that aren't created are still collected\n      // return null so it can be filtered in the next step\n      if (isNone(resource)) {\n        return null;\n      }\n      const pathFromRoot = asAbsoluteWorkspaceUri(resource.uri).path;\n      return MarkdownLink.createUpdateLinkEdit(link, {\n        target: pathFromRoot,\n      });\n    })\n    .filter(linkEdits => !isNone(linkEdits))\n    .sort((a, b) => Position.compareTo(b.range.start, a.range.start));\n  const text = edits.reduce(\n    (text, edit) => TextEdit.apply(text, edit),\n    noteText\n  );\n  return text;\n}\n\nexport function retrieveNoteConfig(explicitModifier: string | undefined): {\n  noteScope: string;\n  noteStyle: string;\n} {\n  let config = getFoamVsCodeConfig<string>(CONFIG_EMBED_NOTE_TYPE); // ex. full-inline\n  let [noteScope, noteStyle] = config.split('-');\n\n  // an explicit modifier will always override corresponding user setting\n  if (explicitModifier !== undefined) {\n    if (['full', 'content'].includes(explicitModifier)) {\n      noteScope = explicitModifier;\n    } else if (['card', 'inline'].includes(explicitModifier)) {\n      noteStyle = explicitModifier;\n    } else {\n      [noteScope, noteStyle] = explicitModifier.split('-');\n    }\n  }\n  return { noteScope, noteStyle };\n}\n\n/**\n * A type of function that gets the desired content of the note\n */\nexport type EmbedNoteExtractor = (\n  note: Resource,\n  parser: ResourceParser,\n  workspace: FoamWorkspace\n) => string;\n\nfunction fullExtractor(\n  note: Resource,\n  parser: ResourceParser,\n  workspace: FoamWorkspace\n): string {\n  let noteText = readFileSync(note.uri.toFsPath()).toString();\n  if (note.uri.fragment.startsWith('^')) {\n    const blockId = note.uri.fragment.slice(1);\n    const block = Resource.findBlock(note, blockId);\n    if (isSome(block)) {\n      noteText = extractBlockContent(noteText, note, block);\n    }\n  } else {\n    const section = Resource.findSection(note, note.uri.fragment);\n    if (isSome(section)) {\n      const rows = noteText.split('\\n');\n      noteText = rows\n        .slice(section.range.start.line, section.range.end.line)\n        .join('\\n');\n    }\n  }\n  noteText = withLinksRelativeToWorkspaceRoot(\n    note.uri,\n    noteText,\n    parser,\n    workspace\n  );\n  return noteText;\n}\n\nfunction contentExtractor(\n  note: Resource,\n  parser: ResourceParser,\n  workspace: FoamWorkspace\n): string {\n  let noteText = readFileSync(note.uri.toFsPath()).toString();\n  if (note.uri.fragment.startsWith('^')) {\n    const blockId = note.uri.fragment.slice(1);\n    const block = Resource.findBlock(note, blockId);\n    if (isSome(block)) {\n      noteText = extractBlockContent(noteText, note, block);\n    }\n  } else {\n    let section = Resource.findSection(note, note.uri.fragment);\n    if (!note.uri.fragment) {\n      // if there's no fragment(section), the wikilink is linking to the entire note,\n      // in which case we need to remove the title. We could just use rows.shift()\n      // but should the note start with blank lines, it will only remove the first blank line\n      // leaving the title\n      // A better way is to find where the actual title starts by assuming it's at section[0]\n      // then we treat it as the same case as link to a section\n      section = note.sections.length ? note.sections[0] : null;\n    }\n    let rows = noteText.split('\\n');\n    if (isSome(section)) {\n      rows = rows.slice(section.range.start.line, section.range.end.line);\n    }\n    rows.shift();\n    noteText = rows.join('\\n');\n  }\n  noteText = withLinksRelativeToWorkspaceRoot(\n    note.uri,\n    noteText,\n    parser,\n    workspace\n  );\n  return noteText;\n}\n\n/**\n * Extracts the content of a block from note text.\n * For heading blocks, returns the section content (heading + body).\n * For other blocks, returns the block content with block anchor markers stripped.\n */\nexport function extractBlockContent(\n  noteText: string,\n  note: Resource,\n  block: Block\n): string {\n  const rows = noteText.split('\\n');\n  if (block.type === 'heading') {\n    const headingText = rows[block.range.start.line];\n    // Find the section by start line rather than reconstructing the label from\n    // raw markdown, which would retain inline formatting (e.g. **bold**) and\n    // fail to match the AST-parsed plain-text label stored in Resource.sections.\n    const section = note.sections.find(\n      s => s.range.start.line === block.range.start.line\n    );\n    if (isSome(section)) {\n      return rows\n        .slice(section.range.start.line, section.range.end.line)\n        .join('\\n')\n        .replace(/\\s\\^[a-zA-Z0-9-]+$/m, '');\n    }\n    return headingText.replace(/\\s\\^[a-zA-Z0-9-]+$/, '');\n  }\n  return rows\n    .slice(block.range.start.line, block.range.end.line + 1)\n    .join('\\n')\n    .replace(/\\s\\^[a-zA-Z0-9-]+$/gm, '');\n}\n\n/**\n * A type of function that renders note content with the desired style in html\n */\nexport type EmbedNoteFormatter = (content: string, md: markdownit) => string;\n\nfunction cardFormatter(content: string, md: markdownit): string {\n  return `<div class=\"embed-container-note\">\\n\\n${content}\\n\\n</div>`;\n}\n\nfunction inlineFormatter(content: string, md: markdownit): string {\n  return content;\n}\n\ninterface ImageParameters {\n  filename: string;\n  width?: string;\n  height?: string;\n  align?: 'center' | 'left' | 'right';\n  alt?: string;\n}\n\nfunction parseImageParameters(\n  wikilink: string,\n  parametersString?: string\n): ImageParameters {\n  const result: ImageParameters = {\n    filename: wikilink,\n  };\n\n  if (!parametersString) {\n    return result;\n  }\n\n  // Remove the leading pipe and split by remaining pipes\n  const params = parametersString.slice(1).split('|');\n\n  if (params.length === 0) {\n    return result;\n  }\n\n  // First parameter is always size\n  const sizeParam = params[0]?.trim();\n  if (sizeParam) {\n    // Parse size parameter: could be \"300\", \"300x200\", \"50%\", \"300px\", etc.\n    // Check for width x height format (but not if it's just a unit like \"px\")\n    const dimensionMatch = sizeParam.match(\n      /^(\\d+(?:\\.\\d+)?(?:px|%|em|rem|vw|vh)?)\\s*x\\s*(\\d+(?:\\.\\d+)?(?:px|%|em|rem|vw|vh)?)$/i\n    );\n    if (dimensionMatch) {\n      // Width x Height format\n      result.width = dimensionMatch[1]?.trim();\n      result.height = dimensionMatch[2]?.trim();\n    } else {\n      // Width only\n      result.width = sizeParam;\n    }\n  }\n\n  // Second parameter could be alignment\n  const alignParam = params[1]?.trim().toLowerCase();\n  if (alignParam && ['center', 'left', 'right'].includes(alignParam)) {\n    result.align = alignParam as 'center' | 'left' | 'right';\n  } else if (alignParam) {\n    // If not alignment, treat as alt text\n    result.alt = params.slice(1).join('|').trim();\n  }\n\n  // Third parameter onwards is alt text (if second wasn't alt text)\n  if (result.align && params.length > 2) {\n    result.alt = params.slice(2).join('|').trim();\n  }\n\n  return result;\n}\n\nfunction generateImageStyles(params: ImageParameters, md: markdownit): string {\n  const { filename, width, height, align, alt } = params;\n\n  // Build CSS styles for the image\n  const styles: string[] = [];\n\n  if (width) {\n    styles.push(`width: ${addDefaultUnit(width)}`);\n\n    // If only width is specified, set height to auto to maintain aspect ratio\n    if (!height) {\n      styles.push('height: auto');\n    }\n  }\n\n  if (height) {\n    styles.push(`height: ${addDefaultUnit(height)}`);\n  }\n\n  const styleAttr = styles.length > 0 ? ` style=\"${styles.join('; ')}\"` : '';\n  const altAttr = alt ? ` alt=\"${escapeHtml(alt)}\"` : ' alt=\"\"';\n\n  // Generate the image HTML\n  const imageHtml = `<img src=\"${md.normalizeLink(\n    filename\n  )}\"${styleAttr}${altAttr}>`;\n\n  // Wrap with alignment if specified\n  if (align) {\n    return `<div style=\"text-align: ${align};\">${imageHtml}</div>`;\n  }\n\n  return imageHtml;\n}\n\nfunction addDefaultUnit(value: string): string {\n  // If no unit is specified and it's a pure number, add 'px'\n  if (/^\\d+(\\.\\d+)?$/.test(value)) {\n    return value + 'px';\n  }\n  return value;\n}\n\nfunction escapeHtml(text: string): string {\n  return text\n    .replace(/&/g, '&amp;')\n    .replace(/</g, '&lt;')\n    .replace(/>/g, '&gt;')\n    .replace(/\"/g, '&quot;')\n    .replace(/'/g, '&#39;');\n}\n\nexport { parseImageParameters, generateImageStyles };\nexport default markdownItWikilinkEmbed;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-navigation.spec.ts",
    "content": "/* @unit-ready */\nimport MarkdownIt from 'markdown-it';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { createTestNote } from '../../test/test-utils';\nimport { getUriInWorkspace } from '../../test/test-utils-vscode';\nimport { default as markdownItWikilinkNavigation } from './wikilink-navigation';\nimport { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';\nimport { default as escapeWikilinkPipes } from './escape-wikilink-pipes';\n\ndescribe('Link generation in preview', () => {\n  const noteA = createTestNote({\n    uri: './path/to/note-a.md',\n    // TODO: this should really just be the workspace folder, use that once #806 is fixed\n    root: getUriInWorkspace('just-a-ref.md'),\n    title: 'My note title',\n    links: [{ slug: 'placeholder' }],\n  });\n  const noteB = createTestNote({\n    uri: './path2/to/note-b.md',\n    root: getUriInWorkspace('just-a-ref.md'),\n    title: 'My second note',\n    sections: ['sec1', 'sec2'],\n  });\n  const ws = new FoamWorkspace().set(noteA).set(noteB);\n\n  const md = [\n    escapeWikilinkPipes,\n    markdownItWikilinkNavigation,\n    markdownItRemoveLinkReferences,\n  ].reduce((acc, extension) => extension(acc, ws), MarkdownIt());\n\n  it('generates a link to a note using the note title as link', () => {\n    expect(md.render(`[[note-a]]`)).toEqual(\n      `<p><a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>${noteA.title}</a></p>\\n`\n    );\n  });\n\n  it('generates a link to a placeholder resource', () => {\n    expect(md.render(`[[placeholder]]`)).toEqual(\n      `<p><a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">placeholder</a></p>\\n`\n    );\n  });\n\n  it('generates a placeholder link to an unknown slug', () => {\n    expect(md.render(`[[random-text]]`)).toEqual(\n      `<p><a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">random-text</a></p>\\n`\n    );\n  });\n\n  it('generates a wikilink even when there is a link reference', () => {\n    const note = `[[note-a]]\n    [note-a]: <note-a.md> \"Note A\"`;\n    expect(md.render(note)).toEqual(\n      `<p><a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>${noteA.title}</a>\\n[note-a]: &lt;note-a.md&gt; &quot;Note A&quot;</p>\\n`\n    );\n  });\n\n  it('generates a link to a section within the note', () => {\n    expect(md.render(`[[#sec]]`)).toEqual(\n      `<p><a class='foam-note-link' title='sec' href='#sec' data-href='#sec'>#sec</a></p>\\n`\n    );\n    expect(md.render(`[[#Section Name]]`)).toEqual(\n      `<p><a class='foam-note-link' title='Section Name' href='#section-name' data-href='#section-name'>#Section Name</a></p>\\n`\n    );\n  });\n\n  it('generates a link to a note with a specific section', () => {\n    expect(md.render(`[[note-b#sec2]]`)).toEqual(\n      `<p><a class='foam-note-link' title='My second note#sec2' href='/path2/to/note-b.md#sec2' data-href='/path2/to/note-b.md#sec2'>${noteB.title}#sec2</a></p>\\n`\n    );\n  });\n\n  it('generates a link to an aliased note with a specific section', () => {\n    expect(md.render(`[[note-b#sec2|this note]]`)).toEqual(\n      `<p><a class='foam-note-link' title='My second note#sec2' href='/path2/to/note-b.md#sec2' data-href='/path2/to/note-b.md#sec2'>this note</a></p>\\n`\n    );\n  });\n\n  it('generates a link to a note if the note exists, but the section does not exist', () => {\n    expect(md.render(`[[note-b#nonexistentsec]]`)).toEqual(\n      `<p><a class='foam-note-link' title='My second note#nonexistentsec' href='/path2/to/note-b.md#nonexistentsec' data-href='/path2/to/note-b.md#nonexistentsec'>${noteB.title}#nonexistentsec</a></p>\\n`\n    );\n  });\n\n  it('generates a link to a note with a specific block anchor', () => {\n    // label/title show '#^blockid' for user clarity; href uses bare '#blockid'\n    // so VS Code's querySelector-based scroll handler doesn't throw on '^'\n    expect(md.render(`[[note-b#^myblock]]`)).toEqual(\n      `<p><a class='foam-note-link' title='My second note#^myblock' href='/path2/to/note-b.md#__myblock' data-href='/path2/to/note-b.md#__myblock'>${noteB.title}#^myblock</a></p>\\n`\n    );\n  });\n\n  it('generates a link to a note with a block anchor and an alias', () => {\n    expect(md.render(`[[note-b#^myblock|this block]]`)).toEqual(\n      `<p><a class='foam-note-link' title='My second note#^myblock' href='/path2/to/note-b.md#__myblock' data-href='/path2/to/note-b.md#__myblock'>this block</a></p>\\n`\n    );\n  });\n\n  it('generates a self-referencing block anchor link', () => {\n    expect(md.render(`[[#^localblock]]`)).toEqual(\n      `<p><a class='foam-note-link' title='^localblock' href='#__localblock' data-href='#__localblock'>#^localblock</a></p>\\n`\n    );\n  });\n\n  it('generates a placeholder link if the note does not exist and a block anchor is specified', () => {\n    expect(md.render(`[[ghost#^someid]]`)).toEqual(\n      `<p><a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">ghost#^someid</a></p>\\n`\n    );\n  });\n\n  it('generates a placeholder link if the note does not exist and a section is specified', () => {\n    expect(md.render(`[[placeholder#sec2]]`)).toEqual(\n      `<p><a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">placeholder#sec2</a></p>\\n`\n    );\n  });\n\n  it('generates a placeholder link with alias if the note does not exist, but alias is given', () => {\n    expect(md.render(`[[placeholder#sec2|this note]]`)).toEqual(\n      `<p><a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">this note</a></p>\\n`\n    );\n  });\n\n  describe('wikilinks with aliases in tables', () => {\n    it('generates a link with alias inside a table cell', () => {\n      const table = `| Week | Week again |\n| --- | --- |\n| [[note-a|W44]] | [[note-b|W45]] |`;\n      const result = md.render(table);\n\n      // Should contain proper links with aliases\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>W44</a>`\n      );\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>W45</a>`\n      );\n    });\n\n    it('generates a link with alias and section inside a table cell', () => {\n      const table = `| Week |\n| --- |\n| [[note-b#sec1|Week 1]] |`;\n      const result = md.render(table);\n\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteB.title}#sec1' href='/path2/to/note-b.md#sec1' data-href='/path2/to/note-b.md#sec1'>Week 1</a>`\n      );\n    });\n\n    it('generates placeholder link with alias inside a table cell', () => {\n      const table = `| Week |\n| --- |\n| [[nonexistent|Placeholder]] |`;\n      const result = md.render(table);\n\n      expect(result).toContain(\n        `<a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">Placeholder</a>`\n      );\n    });\n\n    it('handles multiple wikilinks with aliases in the same table row', () => {\n      const table = `| Col1 | Col2 | Col3 |\n| --- | --- | --- |\n| [[note-a|A]] | [[note-b|B]] | [[placeholder|P]] |`;\n      const result = md.render(table);\n\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>A</a>`\n      );\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>B</a>`\n      );\n      expect(result).toContain(\n        `<a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">P</a>`\n      );\n    });\n\n    it('handles wikilinks without aliases in tables (should still work)', () => {\n      const table = `| Week |\n| --- |\n| [[note-a]] |`;\n      const result = md.render(table);\n\n      expect(result).toContain(\n        `<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>${noteA.title}</a>`\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/preview/wikilink-navigation.ts",
    "content": "/*global markdownit:readonly*/\n\nimport markdownItRegex from 'markdown-it-regex';\nimport * as vscode from 'vscode';\nimport { FoamWorkspace } from '../../core/model/workspace';\nimport { Logger } from '../../core/utils/log';\nimport { toVsCodeUri } from '../../utils/vsc-utils';\nimport { MarkdownLink } from '../../core/services/markdown-link';\nimport { Range } from '../../core/model/range';\nimport { isEmpty } from 'lodash';\nimport { toSlug } from '../../core/utils/slug';\nimport { isNone } from '../../core/utils';\n\nexport const markdownItWikilinkNavigation = (\n  md: markdownit,\n  workspace: FoamWorkspace\n) => {\n  return md.use(markdownItRegex, {\n    name: 'connect-wikilinks',\n    regex: /(?=[^!])\\[\\[([^[\\]]+?)\\]\\]/,\n    replace: (wikilink: string) => {\n      try {\n        const { target, section, blockId, alias } = MarkdownLink.analyzeLink({\n          rawText: '[[' + wikilink + ']]',\n          type: 'wikilink',\n          range: Range.create(0, 0),\n          isEmbed: false,\n        });\n        // formattedFragment is shown to the user in link labels/titles.\n        // linkFragment is used in the href — block ids use the '__' prefix to\n        // match the id emitted by block-anchor-ids.ts and avoid '^' which is\n        // not a valid CSS identifier character.\n        const formattedFragment = blockId\n          ? `#^${blockId}`\n          : section\n          ? `#${section}`\n          : '';\n        const linkFragment = blockId\n          ? `#__${blockId}`\n          : section\n          ? `#${toSlug(section)}`\n          : '';\n        const label = isEmpty(alias) ? `${target}${formattedFragment}` : alias;\n\n        // [[#section]] and [[#^blockid]] links (same-file self-references)\n        if (target.length === 0) {\n          // we don't have a good way to check if the section/block exists within\n          // the open file, so we just create a regular link for it.\n          // Title shows '^blockid' for user clarity; href uses '__blockid' prefix.\n          const fragmentTitle = blockId ? `^${blockId}` : section;\n          return getResourceLink(fragmentTitle, linkFragment, label);\n        }\n\n        const resource = workspace.find(target);\n        if (isNone(resource)) {\n          return getPlaceholderLink(label);\n        }\n\n        const resourceLabel = isEmpty(alias)\n          ? `${resource.title}${formattedFragment}`\n          : alias;\n        const resourceLink = `/${vscode.workspace.asRelativePath(\n          toVsCodeUri(resource.uri),\n          false\n        )}`;\n        return getResourceLink(\n          `${resource.title}${formattedFragment}`,\n          `${resourceLink}${linkFragment}`,\n          resourceLabel\n        );\n      } catch (e) {\n        Logger.error(\n          `Error while creating link for [[${wikilink}]] in Preview panel`,\n          e\n        );\n        return getPlaceholderLink(wikilink);\n      }\n    },\n  });\n};\n\nconst getPlaceholderLink = (content: string) =>\n  `<a class='foam-placeholder-link' title=\"Link to non-existing resource\" href=\"javascript:void(0);\">${content}</a>`;\n\nconst getResourceLink = (title: string, link: string, label: string) =>\n  `<a class='foam-note-link' title='${title}' href='${link}' data-href='${link}'>${label}</a>`;\n\nexport default markdownItWikilinkNavigation;\n"
  },
  {
    "path": "packages/foam-vscode/src/features/refactor.spec.ts",
    "content": "/* @unit-ready */\n\nimport { wait, waitForExpect } from '../test/test-utils';\nimport {\n  closeEditors,\n  createFile,\n  cleanWorkspace,\n  readFile,\n  renameFile,\n  showInEditor,\n  runCommand,\n  deleteFile,\n} from '../test/test-utils-vscode';\nimport { UPDATE_GRAPH_COMMAND_NAME } from './commands/update-graph';\n\ndescribe('Note rename sync', () => {\n  beforeAll(async () => {\n    await closeEditors();\n    await cleanWorkspace();\n  });\n  afterAll(closeEditors);\n\n  describe('wikilinks', () => {\n    it('should sync wikilinks to renamed notes', async () => {\n      const noteA = await createFile(`Content of note A`, [\n        'refactor',\n        'wikilinks',\n        'rename-note-a.md',\n      ]);\n      const noteB = await createFile(\n        `Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`,\n        ['refactor', 'wikilinks', 'rename-note-b.md']\n      );\n      const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [\n        'refactor',\n        'wikilinks',\n        'rename-note-c.md',\n      ]);\n      const { doc } = await showInEditor(noteB.uri);\n\n      const newName = 'renamed-note-a';\n      const newUri = noteA.uri.resolve(newName);\n\n      // wait for the rename events to be propagated\n      await wait(1000);\n      await runCommand(UPDATE_GRAPH_COMMAND_NAME);\n      await renameFile(noteA.uri, newUri);\n\n      await waitForExpect(async () => {\n        // check it updates documents open in editors\n        expect(doc.getText().trim()).toEqual(\n          `Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]`\n        );\n        // and documents not open in editors\n        expect((await readFile(noteC.uri)).trim()).toEqual(\n          `Link to [[${newName}]] from note C.`\n        );\n      }, 1000);\n\n      await deleteFile(newUri);\n      await deleteFile(noteB.uri);\n      await deleteFile(noteC.uri);\n    });\n\n    it('should sync when moving the note to a new folder', async () => {\n      const noteA = await createFile(`Content of note A`, [\n        'refactor',\n        'first',\n        'note-a.md',\n      ]);\n      const noteC = await createFile(`Link to [[note-a]] from note C.`);\n\n      const newUri = noteA.uri.resolve('../note-a.md');\n      // wait for the rename events to be propagated\n      await wait(1000);\n      await runCommand(UPDATE_GRAPH_COMMAND_NAME);\n\n      await renameFile(noteA.uri, newUri);\n\n      await waitForExpect(async () => {\n        const content = await readFile(noteC.uri);\n        expect(content.trim()).toEqual(`Link to [[note-a]] from note C.`);\n      });\n      await deleteFile(newUri);\n      await deleteFile(noteC.uri);\n    });\n  });\n\n  describe('directory renames', () => {\n    it('should sync qualified wikilinks when a folder is renamed', async () => {\n      // note-a exists in two folders, forcing a qualified [[folderA/note-a]] link\n      const noteA = await createFile('Content of A', [\n        'dir-rename',\n        'folderA',\n        'note-a.md',\n      ]);\n      const otherNote = await createFile('Conflicting note', [\n        'dir-rename',\n        'other',\n        'note-a.md',\n      ]);\n      const outside = await createFile('Link to [[folderA/note-a]]', [\n        'dir-rename',\n        'outside.md',\n      ]);\n\n      await wait(1000);\n      await runCommand(UPDATE_GRAPH_COMMAND_NAME);\n\n      const folderAUri = noteA.uri.getDirectory();\n      const folderBUri = folderAUri.getDirectory().joinPath('folderB');\n      await renameFile(folderAUri, folderBUri);\n\n      await waitForExpect(async () => {\n        expect((await readFile(outside.uri)).trim()).toEqual(\n          'Link to [[folderB/note-a]]'\n        );\n      }, 1000);\n\n      await deleteFile(outside.uri);\n      await deleteFile(folderBUri);\n      await deleteFile(otherNote.uri);\n    });\n\n    it('should not change unique wikilinks when a folder is renamed', async () => {\n      // unique-note has no basename conflict — identifier stays [[unique-note]]\n      const noteA = await createFile('Content of A', [\n        'dir-rename-unique',\n        'folderA',\n        'unique-note.md',\n      ]);\n      const outside = await createFile('Link to [[unique-note]]', [\n        'dir-rename-unique',\n        'outside.md',\n      ]);\n\n      await wait(1000);\n      await runCommand(UPDATE_GRAPH_COMMAND_NAME);\n\n      const folderAUri = noteA.uri.getDirectory();\n      const folderBUri = folderAUri.getDirectory().joinPath('folderB');\n      await renameFile(folderAUri, folderBUri);\n\n      await waitForExpect(async () => {\n        expect((await readFile(outside.uri)).trim()).toEqual(\n          'Link to [[unique-note]]'\n        );\n      }, 1000);\n\n      await deleteFile(outside.uri);\n      await deleteFile(folderBUri);\n    });\n  });\n\n  describe('direct links', () => {\n    beforeAll(async () => {\n      await closeEditors();\n      await cleanWorkspace();\n    });\n    beforeEach(closeEditors);\n\n    it('should not update markdown links on rename (delegated to VS Code built-in) - #1069', async () => {\n      const originalLink = `Link to [note](../f1/note-a.md) from note B.`;\n      const noteA = await createFile(\n        `Content of note A. Lorem etc etc etc etc`,\n        ['refactor', 'direct-links', 'f1', 'note-a.md']\n      );\n      const noteB = await createFile(originalLink, [\n        'refactor',\n        'direct-links',\n        'f2',\n        'note-b.md',\n      ]);\n      const { doc } = await showInEditor(noteB.uri);\n\n      const newUri = noteA.uri.resolve('../note-a.md');\n      // wait for the rename events to be propagated\n      await wait(1000);\n      await runCommand(UPDATE_GRAPH_COMMAND_NAME);\n      await renameFile(noteA.uri, newUri);\n\n      // Foam does not update markdown links; the link should remain unchanged\n      await wait(500);\n      expect(doc.getText().trim()).toEqual(originalLink);\n\n      await deleteFile(newUri);\n      await deleteFile(noteB.uri);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/refactor.test.ts",
    "content": "import {\n  computeWikilinkRenameEdits,\n  computeDirectoryWikilinkRenameEdits,\n} from './refactor';\nimport {\n  createNoteFromMarkdown,\n  createTestWorkspace,\n} from '../test/test-utils';\nimport { FoamGraph } from '../core/model/graph';\nimport { URI } from '../core/model/uri';\nimport { Foam } from '../core/model/foam';\nimport { Resource } from '../core/model/note';\n\nconst root = URI.file('/workspace');\n\nfunction createFoam(...notes: Resource[]): Foam {\n  const workspace = createTestWorkspace([root]);\n  notes.forEach(n => workspace.set(n));\n  const graph = FoamGraph.fromWorkspace(workspace);\n  return { workspace, graph } as unknown as Foam;\n}\n\ndescribe('computeWikilinkRenameEdits', () => {\n  it('returns empty array when the note has no backlinks', () => {\n    const noteA = createNoteFromMarkdown('note-a.md', 'Content of A', root);\n    const foam = createFoam(noteA);\n\n    const edits = computeWikilinkRenameEdits(\n      foam,\n      noteA.uri,\n      root.resolve('renamed.md')\n    );\n\n    expect(edits).toEqual([]);\n  });\n\n  it('updates the wikilink identifier when a note is renamed', () => {\n    const noteA = createNoteFromMarkdown('note-a.md', 'Content of A', root);\n    const noteB = createNoteFromMarkdown(\n      'note-b.md',\n      'Link to [[note-a]]',\n      root\n    );\n    const foam = createFoam(noteA, noteB);\n    const newUri = root.resolve('renamed-note-a.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    expect(edits).toHaveLength(1);\n    expect(edits[0].uri).toEqual(noteB.uri);\n    expect(edits[0].edit.newText).toEqual('[[renamed-note-a]]');\n  });\n\n  it('uses the best identifier based on the new note location', () => {\n    const noteA = createNoteFromMarkdown(\n      'refactor/wikilink/first/note-a.md',\n      'Content of A',\n      root\n    );\n    const noteB = createNoteFromMarkdown(\n      'refactor/wikilink/second/note-b.md',\n      'Content of B',\n      root\n    );\n    const noteC = createNoteFromMarkdown(\n      'note-c.md',\n      'Link to [[note-a]]',\n      root\n    );\n    const foam = createFoam(noteA, noteB, noteC);\n    // Rename note-a to first/note-b — now ambiguous with second/note-b\n    const newUri = root.resolve('refactor/wikilink/first/note-b.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    expect(edits[0].edit.newText).toEqual('[[first/note-b]]');\n  });\n\n  it('uses the best identifier when moving a note to another directory', () => {\n    const noteA = createNoteFromMarkdown(\n      'refactor/wikilink/first/note-a.md',\n      'Content of A',\n      root\n    );\n    const noteB = createNoteFromMarkdown(\n      'refactor/wikilink/second/note-b.md',\n      'Content of B',\n      root\n    );\n    const noteC = createNoteFromMarkdown(\n      'note-c.md',\n      'Link to [[note-a]]',\n      root\n    );\n    const foam = createFoam(noteA, noteB, noteC);\n    // Moving note-a into second/ — still unique, so short identifier suffices\n    const newUri = root.resolve('refactor/wikilink/second/note-a.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    expect(edits[0].edit.newText).toEqual('[[note-a]]');\n  });\n\n  it('preserves the alias when updating a wikilink', () => {\n    const noteA = createNoteFromMarkdown('note-a.md', 'Content of A', root);\n    const noteB = createNoteFromMarkdown(\n      'note-b.md',\n      'Link to [[note-a|Alias]]',\n      root\n    );\n    const foam = createFoam(noteA, noteB);\n    const newUri = root.resolve('new-note-a.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    expect(edits[0].edit.newText).toEqual('[[new-note-a|Alias]]');\n  });\n\n  it('preserves the section when updating a wikilink', () => {\n    const noteA = createNoteFromMarkdown('note-a.md', 'Content of A', root);\n    const noteB = createNoteFromMarkdown(\n      'note-b.md',\n      'Link to [[note-a#Section]]',\n      root\n    );\n    const foam = createFoam(noteA, noteB);\n    const newUri = root.resolve('new-note-with-section.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    expect(edits[0].edit.newText).toEqual('[[new-note-with-section#Section]]');\n  });\n\n  it('does not return edits for markdown links (delegated to VS Code built-in)', () => {\n    const noteA = createNoteFromMarkdown('note-a.md', 'Content of A', root);\n    const noteB = createNoteFromMarkdown(\n      'note-b.md',\n      'Link to [[note-a]] and [direct](./note-a.md)',\n      root\n    );\n    const foam = createFoam(noteA, noteB);\n    const newUri = root.resolve('renamed.md');\n\n    const edits = computeWikilinkRenameEdits(foam, noteA.uri, newUri);\n\n    // Only the wikilink should produce an edit; the markdown link should be skipped\n    expect(edits).toHaveLength(1);\n    expect(edits[0].edit.newText).toEqual('[[renamed]]');\n  });\n});\n\ndescribe('computeDirectoryWikilinkRenameEdits', () => {\n  it('returns empty array when no Foam resources are inside the directory', () => {\n    const outside = createNoteFromMarkdown('outside.md', 'Content', root);\n    const foam = createFoam(outside);\n    const oldDirUri = URI.file('/empty-folder');\n    const newDirUri = URI.file('/renamed-folder');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits).toEqual([]);\n  });\n\n  it('updates qualified wikilinks pointing to files inside the renamed folder', () => {\n    // note-a exists in two folders, so it must be qualified as [[folderA/note-a]]\n    const noteA = createNoteFromMarkdown(\n      'folderA/note-a.md',\n      'Content of A',\n      root\n    );\n    const conflict = createNoteFromMarkdown(\n      'other/note-a.md',\n      'Conflicting note',\n      root\n    );\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Link to [[folderA/note-a]]',\n      root\n    );\n    const foam = createFoam(noteA, conflict, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits).toHaveLength(1);\n    expect(edits[0].uri).toEqual(outside.uri);\n    expect(edits[0].edit.newText).toEqual('[[folderB/note-a]]');\n  });\n\n  it('produces a no-op edit for unique wikilinks that remain unique after rename', () => {\n    // unique-note has no basename conflicts — link is [[unique-note]] before and after\n    const noteA = createNoteFromMarkdown(\n      'folderA/unique-note.md',\n      'Content',\n      root\n    );\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Link to [[unique-note]]',\n      root\n    );\n    const foam = createFoam(noteA, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits).toHaveLength(1);\n    expect(edits[0].edit.newText).toEqual('[[unique-note]]');\n  });\n\n  it('correctly disambiguates files within the renamed folder that share a basename', () => {\n    // Both files have basename 'note', forcing path-qualified identifiers\n    const noteA = createNoteFromMarkdown(\n      'folderA/note.md',\n      'Content of A',\n      root\n    );\n    const noteSub = createNoteFromMarkdown(\n      'folderA/sub/note.md',\n      'Content of Sub',\n      root\n    );\n    // Current identifiers: [[folderA/note]] and [[sub/note]]\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Links to [[folderA/note]] and [[sub/note]]',\n      root\n    );\n    const foam = createFoam(noteA, noteSub, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    // With the future workspace, folderB/note and folderB/sub/note correctly\n    // compete with each other — the naive exclude-only approach would return\n    // 'note' for both (ambiguous).\n    expect(edits.some(e => e.edit.newText === '[[folderB/note]]')).toBe(true);\n    expect(edits.every(e => e.edit.newText !== '[[note]]')).toBe(true);\n  });\n\n  it('updates wikilinks inside the folder pointing to other files inside the same folder', () => {\n    const noteA = createNoteFromMarkdown(\n      'folderA/note-a.md',\n      'Content of A',\n      root\n    );\n    const conflict = createNoteFromMarkdown(\n      'other/note-a.md',\n      'Conflicting',\n      root\n    );\n    // noteB is inside folderA and links to noteA (also inside folderA)\n    const noteB = createNoteFromMarkdown(\n      'folderA/note-b.md',\n      'Link to [[folderA/note-a]]',\n      root\n    );\n    const foam = createFoam(noteA, conflict, noteB);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    // The edit goes to noteB's old URI (it will be renamed to folderB/note-b.md by VS Code)\n    expect(edits).toHaveLength(1);\n    expect(edits[0].uri).toEqual(noteB.uri);\n    expect(edits[0].edit.newText).toEqual('[[folderB/note-a]]');\n  });\n\n  it('updates directory-style wikilinks [[folderA]] → [[folderB]] when the directory is renamed', () => {\n    // folderA/index.md is the directory index, so it can be linked as [[folderA]]\n    const index = createNoteFromMarkdown(\n      'folderA/index.md',\n      'Index of folderA',\n      root\n    );\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Link to [[folderA]]',\n      root\n    );\n    const foam = createFoam(index, outside);\n    const oldDirUri = index.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits).toHaveLength(1);\n    expect(edits[0].edit.newText).toEqual('[[folderB]]');\n  });\n\n  it('does not produce edits for links from inside the folder pointing outside', () => {\n    // noteA links to outside, but we only care about backlinks of noteA (there are none)\n    const noteA = createNoteFromMarkdown(\n      'folderA/note-a.md',\n      'Link to [[outside]]',\n      root\n    );\n    const outside = createNoteFromMarkdown('outside.md', 'Content', root);\n    const foam = createFoam(noteA, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits).toEqual([]);\n  });\n\n  it('preserves aliases when updating wikilinks to files inside the renamed folder', () => {\n    const noteA = createNoteFromMarkdown(\n      'folderA/note-a.md',\n      'Content of A',\n      root\n    );\n    const conflict = createNoteFromMarkdown(\n      'other/note-a.md',\n      'Conflicting',\n      root\n    );\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Link to [[folderA/note-a|My Alias]]',\n      root\n    );\n    const foam = createFoam(noteA, conflict, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits[0].edit.newText).toEqual('[[folderB/note-a|My Alias]]');\n  });\n\n  it('preserves section anchors when updating wikilinks to files inside the renamed folder', () => {\n    const noteA = createNoteFromMarkdown(\n      'folderA/note-a.md',\n      'Content of A',\n      root\n    );\n    const conflict = createNoteFromMarkdown(\n      'other/note-a.md',\n      'Conflicting',\n      root\n    );\n    const outside = createNoteFromMarkdown(\n      'outside.md',\n      'Link to [[folderA/note-a#Section]]',\n      root\n    );\n    const foam = createFoam(noteA, conflict, outside);\n    const oldDirUri = noteA.uri.getDirectory();\n    const newDirUri = URI.file('/folderB');\n\n    const edits = computeDirectoryWikilinkRenameEdits(\n      foam,\n      oldDirUri,\n      newDirUri\n    );\n\n    expect(edits[0].edit.newText).toEqual('[[folderB/note-a#Section]]');\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/refactor.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { MarkdownLink } from '../core/services/markdown-link';\nimport { Logger } from '../core/utils/log';\nimport { getFoamVsCodeConfig } from '../services/config';\nimport { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';\nimport { URI } from '../core/model/uri';\nimport { Resource } from '../core/model/note';\nimport { WorkspaceTextEdit } from '../core/services/text-edit';\nimport { FoamWorkspace } from '../core/model/workspace';\n\nconst MARKDOWN_LINK_NOTIFICATION_KEY =\n  'foam.links.sync.markdownLinkNotificationShown';\n\n/**\n * Builds a future-state workspace reflecting the given renames.\n * Old URIs are removed and new URIs are added, so that getIdentifier correctly\n * disambiguates renamed files against each other and against the rest of the workspace.\n */\nfunction buildFutureWorkspace(\n  workspace: FoamWorkspace,\n  renames: Array<{ oldResource: Resource; newUri: URI }>\n): FoamWorkspace {\n  const future = new FoamWorkspace(workspace.roots, workspace.defaultExtension);\n  for (const resource of workspace.list()) {\n    future.set(resource);\n  }\n  for (const { oldResource, newUri } of renames) {\n    future.delete(oldResource.uri);\n    future.set({ ...oldResource, uri: newUri });\n  }\n  return future;\n}\n\n/**\n * `getDirectoryIdentifier` normalizes paths to lowercase for case-insensitive\n * matching, so its return value is always lowercase. This helper restores\n * the correct casing by matching the identifier's segments against the actual\n * path segments of the directory URI.\n *\n * Example: lowerId='folderb', dirPath='/folderB' → 'folderB'\n * Example: lowerId='parent/folderb', dirPath='/parent/folderB' → 'parent/folderB'\n */\nfunction toCorrectCase(lowerId: string, dirUri: URI): string {\n  const idSegments = lowerId.split('/');\n  const pathSegments = dirUri.path.split('/').filter(Boolean);\n  return pathSegments.slice(-idSegments.length).join('/');\n}\n\n/**\n * Core computation: given a list of (oldResource → newUri) pairs, computes\n * all wikilink edits needed in files that link to those resources.\n *\n * Uses a future-state workspace for identifier computation, so that:\n * - Files being renamed don't compete with their own old paths.\n * - Files within the same batch (e.g. a directory rename) correctly disambiguate\n *   against each other in the post-rename state.\n * - Directory-style identifiers (e.g. [[folderA]] → [[folderB]]) are preserved.\n */\nfunction computeRenameEditsForPairs(\n  foam: Foam,\n  renames: Array<{ oldResource: Resource; newUri: URI }>\n): WorkspaceTextEdit[] {\n  if (renames.length === 0) {\n    return [];\n  }\n\n  const futureWorkspace = buildFutureWorkspace(foam.workspace, renames);\n  const allEdits: WorkspaceTextEdit[] = [];\n\n  for (const { oldResource, newUri } of renames) {\n    const connections = foam.graph.getBacklinks(oldResource.uri);\n    // getDirectoryIdentifier normalizes to lowercase; comparison must be case-insensitive\n    const oldDirIdentifier = foam.workspace.getDirectoryIdentifier(\n      oldResource.uri\n    );\n\n    for (const connection of connections) {\n      if (connection.link.type !== 'wikilink') {\n        continue;\n      }\n      const { target: linkTarget } = MarkdownLink.analyzeLink(connection.link);\n      let identifier: string;\n      if (\n        oldDirIdentifier &&\n        linkTarget.toLocaleLowerCase() === oldDirIdentifier\n      ) {\n        // Link uses a directory-style identifier (e.g. [[folderA]]). Compute the\n        // new directory identifier and restore its correct casing from the URI.\n        const newDirUri = newUri.getDirectory();\n        const lowerId = futureWorkspace.getDirectoryIdentifier(newUri);\n        identifier = lowerId\n          ? toCorrectCase(lowerId, newDirUri)\n          : futureWorkspace.getIdentifier(newUri);\n      } else {\n        identifier = futureWorkspace.getIdentifier(newUri);\n      }\n\n      const edit = MarkdownLink.createUpdateLinkEdit(connection.link, {\n        target: identifier,\n      });\n      allEdits.push({ uri: connection.source, edit });\n    }\n  }\n\n  return allEdits;\n}\n\nexport function computeWikilinkRenameEdits(\n  foam: Foam,\n  oldUri: URI,\n  newUri: URI\n): WorkspaceTextEdit[] {\n  const oldResource = foam.workspace.find(oldUri);\n  if (!oldResource) {\n    return [];\n  }\n  return computeRenameEditsForPairs(foam, [{ oldResource, newUri }]);\n}\n\nexport function computeDirectoryWikilinkRenameEdits(\n  foam: Foam,\n  oldDirUri: URI,\n  newDirUri: URI\n): WorkspaceTextEdit[] {\n  const oldDirPath = oldDirUri.path;\n  const renames = foam.workspace\n    .list()\n    .filter(r => r.uri.path.startsWith(oldDirPath + '/'))\n    .map(r => ({\n      oldResource: r,\n      newUri: newDirUri.joinPath(r.uri.path.slice(oldDirPath.length + 1)),\n    }));\n  return computeRenameEditsForPairs(foam, renames);\n}\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  context.subscriptions.push(\n    vscode.workspace.onWillRenameFiles(async e => {\n      if (!getFoamVsCodeConfig<boolean>('links.sync.enable', true)) {\n        return;\n      }\n      const renameEdits = new vscode.WorkspaceEdit();\n      let hasMarkdownBacklinks = false;\n      for (const { oldUri, newUri } of e.files) {\n        const foamOldUri = fromVsCodeUri(oldUri);\n        const foamNewUri = fromVsCodeUri(newUri);\n\n        const isDirectory =\n          (await vscode.workspace.fs.stat(oldUri)).type ===\n          vscode.FileType.Directory;\n\n        const wikilinkEdits = isDirectory\n          ? computeDirectoryWikilinkRenameEdits(foam, foamOldUri, foamNewUri)\n          : computeWikilinkRenameEdits(foam, foamOldUri, foamNewUri);\n\n        for (const { uri, edit } of wikilinkEdits) {\n          renameEdits.replace(\n            toVsCodeUri(uri),\n            toVsCodeRange(edit.range),\n            edit.newText\n          );\n        }\n\n        // For directory renames, remove stale workspace entries for files under\n        // the old directory path. On macOS (FSEvents), the file watcher fires\n        // directory-level events rather than per-file events, so Foam never\n        // receives individual delete events for those files. We clean up here,\n        // synchronously, inside the awaited onWillRenameFiles handler, before\n        // VS Code performs the actual rename.\n        if (isDirectory) {\n          const oldDirPath = foamOldUri.path;\n          foam.workspace\n            .list()\n            .filter(r => r.uri.path.startsWith(oldDirPath + '/'))\n            .forEach(resource => foam.workspace.delete(resource.uri));\n        }\n\n        if (!isDirectory) {\n          if (\n            foam.graph\n              .getBacklinks(foamOldUri)\n              .some(c => c.link.type === 'link')\n          ) {\n            hasMarkdownBacklinks = true;\n          }\n        }\n      }\n\n      try {\n        if (renameEdits.size > 0) {\n          // We break the update by file because applying it at once was causing\n          // dirty state and editors not always saving or closing\n          for (const renameEditForUri of renameEdits.entries()) {\n            const [uri, edits] = renameEditForUri;\n            const fileEdits = new vscode.WorkspaceEdit();\n            fileEdits.set(uri, edits);\n            await vscode.workspace.applyEdit(fileEdits);\n            const editor = await vscode.workspace.openTextDocument(uri);\n            // Because the save happens within 50ms of opening the doc, it will be then closed\n            editor.save();\n          }\n\n          // Reporting\n          const nUpdates = renameEdits.entries().reduce((acc, entry) => {\n            return (acc += entry[1].length);\n          }, 0);\n          const links = nUpdates > 1 ? 'links' : 'link';\n          const nFiles = renameEdits.size;\n          const files = nFiles > 1 ? 'files' : 'file';\n          Logger.info(\n            `Updated links in the following files:`,\n            ...renameEdits\n              .entries()\n              .map(e => vscode.workspace.asRelativePath(e[0]))\n          );\n          vscode.window.showInformationMessage(\n            `Updated ${nUpdates} ${links} across ${nFiles} ${files}.`\n          );\n        }\n      } catch (e) {\n        Logger.error('Error while updating references to file', e);\n        vscode.window.showErrorMessage(\n          `Foam couldn't update the links to ${vscode.workspace.asRelativePath(\n            e.newUri\n          )}. Check the logs for error details.`\n        );\n      }\n\n      // On the first rename where there are markdown backlinks, nudge the user\n      // to enable VS Code's built-in markdown link update setting if they haven't already.\n      if (\n        hasMarkdownBacklinks &&\n        !context.globalState.get(MARKDOWN_LINK_NOTIFICATION_KEY)\n      ) {\n        const vsCodeMarkdownSetting = vscode.workspace\n          .getConfiguration('markdown')\n          .get<string>('updateLinksOnFileMove.enabled', 'never');\n        if (vsCodeMarkdownSetting === 'never') {\n          const choice = await vscode.window.showInformationMessage(\n            \"Foam updated your wikilinks. To also update standard markdown links on rename, enable VS Code's built-in setting.\",\n            'Enable',\n            'Dismiss'\n          );\n          if (choice === 'Enable') {\n            await vscode.workspace\n              .getConfiguration('markdown')\n              .update(\n                'updateLinksOnFileMove.enabled',\n                'always',\n                vscode.ConfigurationTarget.Global\n              );\n          }\n        }\n        await context.globalState.update(MARKDOWN_LINK_NOTIFICATION_KEY, true);\n      }\n    }),\n\n    vscode.workspace.onWillDeleteFiles(async e => {\n      for (const uri of e.files) {\n        const stat = await vscode.workspace.fs.stat(uri);\n        if (stat.type !== vscode.FileType.Directory) {\n          continue;\n        }\n        // On platforms where the file watcher fires directory-level events\n        // (e.g. macOS FSEvents, Linux inotify), Foam never receives individual\n        // delete events for files inside a deleted directory. We clean up here,\n        // synchronously, inside the awaited onWillDeleteFiles handler, so that\n        // the workspace stays consistent. The delete events fired here allow\n        // downstream clients (graph, tags, etc.) to update their state.\n        const foamUri = fromVsCodeUri(uri);\n        foam.workspace\n          .list()\n          .filter(r => r.uri.path.startsWith(foamUri.path + '/'))\n          .forEach(resource => foam.workspace.delete(resource.uri));\n      }\n    })\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/tag-completion.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { FoamTags } from '../core/model/tags';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { createTestNote } from '../test/test-utils';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { fromVsCodeUri } from '../utils/vsc-utils';\nimport { TagCompletionProvider } from './tag-completion';\n\ndescribe('Tag Completion', () => {\n  const root = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);\n  const ws = new FoamWorkspace();\n  ws.set(\n    createTestNote({\n      root,\n      uri: 'file-name.md',\n      tags: ['primary'],\n    })\n  )\n    .set(\n      createTestNote({\n        root,\n        uri: 'File name with spaces.md',\n        tags: ['secondary'],\n      })\n    )\n    .set(\n      createTestNote({\n        root,\n        uri: 'path/to/file.md',\n        links: [{ slug: 'placeholder text' }],\n        tags: ['primary', 'third'],\n      })\n    );\n  const foamTags = FoamTags.fromWorkspace(ws);\n\n  beforeAll(async () => {\n    await cleanWorkspace();\n  });\n\n  afterAll(async () => {\n    ws.dispose();\n    foamTags.dispose();\n    await cleanWorkspace();\n  });\n\n  beforeEach(async () => {\n    await closeEditors();\n  });\n\n  it('should not return any tags for empty documents', async () => {\n    const { uri } = await createFile('');\n    const { doc } = await showInEditor(uri);\n    const provider = new TagCompletionProvider(foamTags);\n\n    const tags = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 0)\n    );\n\n    expect(foamTags.tags.get('primary')).toBeTruthy();\n    expect(tags).toBeNull();\n  });\n\n  it('should provide a suggestion when typing #prim', async () => {\n    const { uri } = await createFile('#prim');\n    const { doc } = await showInEditor(uri);\n    const provider = new TagCompletionProvider(foamTags);\n\n    const tags = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 5)\n    );\n\n    expect(foamTags.tags.get('primary')).toBeTruthy();\n    expect(tags.items.length).toEqual(3);\n  });\n\n  it('should not provide suggestions when inside a wikilink', async () => {\n    const { uri } = await createFile('[[#prim');\n    const { doc } = await showInEditor(uri);\n    const provider = new TagCompletionProvider(foamTags);\n\n    const tags = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 7)\n    );\n\n    expect(foamTags.tags.get('primary')).toBeTruthy();\n    expect(tags).toBeNull();\n  });\n\n  it('should not provide suggestions when inside a markdown heading #1182', async () => {\n    const { uri } = await createFile('# primary');\n    const { doc } = await showInEditor(uri);\n    const provider = new TagCompletionProvider(foamTags);\n\n    const tags = await provider.provideCompletionItems(\n      doc,\n      new vscode.Position(0, 7)\n    );\n\n    expect(foamTags.tags.get('primary')).toBeTruthy();\n    expect(tags).toBeNull();\n  });\n\n  describe('has robust triggering #1189', () => {\n    it('should provide multiple suggestions when typing #', async () => {\n      const { uri } = await createFile(`# Title\n\n#`);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 1)\n      );\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide multiple suggestions when typing # on line with match', async () => {\n      const { uri } = await createFile('Here is #my-tag and #');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(0, 21)\n      );\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide multiple suggestions when typing # at EOL', async () => {\n      const { uri } = await createFile(`# Title\n\n#\nmore text\n`);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 1)\n      );\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should not provide a suggestion when typing `# `', async () => {\n      const { uri } = await createFile(`# Title\n\n# `);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 2)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n\n    it('should not provide a suggestion when typing `#{non-match}`', async () => {\n      const { uri } = await createFile(`# Title\n\n#$`);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 2)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n\n    it('should not provide a suggestion when typing `##`', async () => {\n      const { uri } = await createFile(`# Title\n\n##`);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 2)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n\n    it('should not provide a suggestion when typing `# ` in a line that already matched', async () => {\n      const { uri } = await createFile('here is #primary and now # ');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(0, 29)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n  });\n\n  describe('works inside front-matter #1184', () => {\n    it('should provide suggestions when on `tags:` in the front-matter', async () => {\n      const { uri } = await createFile(`---\ncreated: 2023-01-01\ntags: prim`);\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 10)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions when on `tags:` in the front-matter with leading `[`', async () => {\n      const { uri } = await createFile('---\\ntags: [');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(1, 7)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions when on `tags:` in the front-matter with `#`', async () => {\n      const { uri } = await createFile('---\\ntags: #');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(1, 7)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions when on `tags:` in the front-matter when tags are comma separated', async () => {\n      const { uri } = await createFile(\n        '---\\ncreated: 2023-01-01\\ntags: secondary, prim'\n      );\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 21)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions when on `tags:` in the front-matter in middle of comma separated', async () => {\n      const { uri } = await createFile(\n        '---\\ncreated: 2023-01-01\\ntags: second, prim'\n      );\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 12)\n      );\n\n      expect(foamTags.tags.get('secondary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions in `tags:` on separate line with leading space', async () => {\n      const { uri } = await createFile('---\\ntags: second, prim\\n ');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 1)\n      );\n\n      expect(foamTags.tags.get('secondary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should provide suggestions in `tags:` on separate line with leading ` - `', async () => {\n      const { uri } = await createFile('---\\ntags:\\n - ');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 3)\n      );\n\n      expect(foamTags.tags.get('secondary')).toBeTruthy();\n      expect(tags.items.length).toEqual(3);\n    });\n\n    it('should not provide suggestions when on non-`tags:` in the front-matter', async () => {\n      const { uri } = await createFile('---\\ntags: prim\\ntitle: prim');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 11)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n\n    it('should not provide suggestions when outside the front-matter without `#` key', async () => {\n      const { uri } = await createFile(\n        '---\\ncreated: 2023-01-01\\ntags: prim\\n---\\ncontent\\ntags: prim'\n      );\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(5, 10)\n      );\n\n      expect(foamTags.tags.get('primary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n\n    it('should not provide suggestions in `tags:` on separate line with leading ` -`', async () => {\n      const { uri } = await createFile('---\\ntags:\\n -');\n      const { doc } = await showInEditor(uri);\n      const provider = new TagCompletionProvider(foamTags);\n\n      const tags = await provider.provideCompletionItems(\n        doc,\n        new vscode.Position(2, 2)\n      );\n\n      expect(foamTags.tags.get('secondary')).toBeTruthy();\n      expect(tags).toBeNull();\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/tag-completion.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { FoamTags } from '../core/model/tags';\nimport { isInFrontMatter, isOnYAMLKeywordLine } from '../core/utils/md';\nimport { getFoamDocSelectors } from '../services/editor';\n\n// this regex is different from HASHTAG_REGEX in that it does not look for a\n// #+character. It uses a negative look-ahead for `# `\nconst HASH_REGEX =\n  /(?<=^|\\s)#(?![ \\t#])([0-9]*[\\p{L}\\p{Emoji_Presentation}\\p{N}/_-]*)/dgu;\nconst MAX_LINES_FOR_FRONT_MATTER = 50;\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  context.subscriptions.push(\n    vscode.languages.registerCompletionItemProvider(\n      getFoamDocSelectors(),\n      new TagCompletionProvider(foam.tags),\n      '#'\n    )\n  );\n}\n\nexport class TagCompletionProvider\n  implements vscode.CompletionItemProvider<vscode.CompletionItem>\n{\n  constructor(private foamTags: FoamTags) {}\n\n  provideCompletionItems(\n    document: vscode.TextDocument,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {\n    const cursorPrefix = document\n      .lineAt(position)\n      .text.substr(0, position.character);\n\n    const beginningOfFileText = document.getText(\n      new vscode.Range(\n        new vscode.Position(0, 0),\n        new vscode.Position(\n          position.line < MAX_LINES_FOR_FRONT_MATTER\n            ? position.line\n            : MAX_LINES_FOR_FRONT_MATTER,\n          position.character\n        )\n      )\n    );\n\n    const isHashMatch = cursorPrefix.match(HASH_REGEX) !== null;\n    const inFrontMatter = isInFrontMatter(beginningOfFileText, position.line);\n\n    if (!isHashMatch && !inFrontMatter) {\n      return null;\n    }\n\n    return inFrontMatter\n      ? this.createTagsForFrontMatter(beginningOfFileText, position)\n      : this.createTagsForContent(cursorPrefix, position);\n  }\n\n  private createTagsForFrontMatter(\n    content: string,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {\n    const FRONT_MATTER_PREVIOUS_CHARACTER = /[#[\\s\\w]/g;\n\n    const lines = content.split('\\n');\n    if (position.line >= lines.length) {\n      return null;\n    }\n\n    const cursorPrefix = lines[position.line].substring(0, position.character);\n\n    const isTagsMatch =\n      isOnYAMLKeywordLine(content, 'tags') &&\n      cursorPrefix\n        .charAt(position.character - 1)\n        .match(FRONT_MATTER_PREVIOUS_CHARACTER);\n\n    if (!isTagsMatch) {\n      return null;\n    }\n\n    const [lastMatchStartIndex, lastMatchEndIndex] = this.tagMatchIndices(\n      cursorPrefix,\n      HASH_REGEX\n    );\n\n    const isHashMatch = cursorPrefix.match(HASH_REGEX) !== null;\n    if (isHashMatch && lastMatchEndIndex !== position.character) {\n      return null;\n    }\n\n    const completionTags = this.createCompletionTagItems();\n    // We are in the front matter and we typed #, remove the `#`\n    if (isHashMatch) {\n      completionTags.forEach(item => {\n        item.additionalTextEdits = [\n          vscode.TextEdit.delete(\n            new vscode.Range(\n              position.line,\n              lastMatchStartIndex,\n              position.line,\n              lastMatchStartIndex + 1\n            )\n          ),\n        ];\n      });\n    }\n\n    return new vscode.CompletionList(completionTags);\n  }\n\n  private createTagsForContent(\n    content: string,\n    position: vscode.Position\n  ): vscode.ProviderResult<vscode.CompletionList<vscode.CompletionItem>> {\n    const [, lastMatchEndIndex] = this.tagMatchIndices(content, HASH_REGEX);\n    if (lastMatchEndIndex !== position.character) {\n      return null;\n    }\n\n    return new vscode.CompletionList(this.createCompletionTagItems());\n  }\n\n  private createCompletionTagItems(): vscode.CompletionItem[] {\n    const completionTags = [];\n    [...this.foamTags.tags].forEach(([tag]) => {\n      const item = new vscode.CompletionItem(\n        tag,\n        vscode.CompletionItemKind.Text\n      );\n\n      item.insertText = `${tag}`;\n      item.documentation = tag;\n\n      completionTags.push(item);\n    });\n    return completionTags;\n  }\n\n  private tagMatchIndices(content: string, match: RegExp): number[] {\n    // check the match group length.\n    // find the last match group, and ensure the end of that group is\n    // at the cursor position.\n    // This excludes both `#%` and also `here is #my-app1 and now # ` with\n    // trailing space\n    const matches = Array.from(content.matchAll(match));\n    if (matches.length === 0) {\n      return [-1, -1];\n    }\n\n    const lastMatch = matches[matches.length - 1];\n    const lastMatchStartIndex = lastMatch.index;\n    const lastMatchEndIndex = lastMatch[0].length + lastMatchStartIndex;\n\n    return [lastMatchStartIndex, lastMatchEndIndex];\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/tag-rename-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { TagEdit } from '../core/services/tag-edit';\nimport {\n  fromVsCodeUri,\n  toVsCodeRange,\n  toVsCodeWorkspaceEdit,\n} from '../utils/vsc-utils';\nimport { Logger } from '../core/utils/log';\nimport { Position } from '../core/model/position';\n\n/**\n * Activates the tag rename provider for native F2 rename support.\n *\n * This provider enables users to press F2 on any tag in markdown files\n * to trigger VS Code's built-in rename functionality, providing a native\n * experience for tag renaming that feels like renaming variables in code.\n *\n * @param context VS Code extension context for registering the provider\n * @param foamPromise Promise that resolves to the initialized Foam instance\n */\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n  const provider = new TagRenameProvider(foam);\n\n  context.subscriptions.push(\n    vscode.languages.registerRenameProvider('markdown', provider)\n  );\n}\n\n/**\n * VS Code rename provider for Foam tags.\n *\n * This class implements the VS Code RenameProvider interface to enable\n * native F2 rename support for tags. It provides seamless integration\n * with VS Code's rename system while leveraging Foam's tag infrastructure.\n */\nexport class TagRenameProvider implements vscode.RenameProvider {\n  constructor(private foam: Foam) {}\n\n  /**\n   * Prepare a rename operation for VS Code's F2 rename functionality.\n   *\n   * This method is called when the user presses F2 or invokes \"Rename Symbol\"\n   * from the context menu. It determines if the cursor is positioned on a tag\n   * and returns the precise range and placeholder text for the rename operation.\n   *\n   * @param document The VS Code text document containing the tag\n   * @param position The cursor position where F2 was pressed\n   * @param token Cancellation token for the operation\n   * @returns Range and placeholder for the tag if found, throws error otherwise\n   * @throws Error if cursor is not positioned on a tag or tag range cannot be found\n   */\n  prepareRename(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    token: vscode.CancellationToken\n  ): vscode.ProviderResult<\n    vscode.Range | { range: vscode.Range; placeholder: string }\n  > {\n    const fileUri = fromVsCodeUri(document.uri);\n    const foamPosition = Position.create(position.line, position.character);\n    const tagLabel = TagEdit.getTagAtPosition(\n      this.foam.tags,\n      fileUri,\n      foamPosition\n    );\n\n    if (!tagLabel) {\n      // Not on a tag, reject the rename\n      throw new Error('Cannot rename: cursor is not on a tag');\n    }\n\n    // Find the exact range of this tag occurrence\n    const tagLocations = this.foam.tags.tags.get(tagLabel) ?? [];\n    for (const location of tagLocations) {\n      if (location.uri.toString() !== fileUri.toString()) {\n        continue;\n      }\n\n      const range = location.range;\n      const positionInRange =\n        (position.line === range.start.line &&\n          position.character >= range.start.character &&\n          position.line === range.end.line &&\n          position.character <= range.end.character) ||\n        (position.line > range.start.line && position.line < range.end.line);\n\n      if (positionInRange) {\n        return {\n          range: toVsCodeRange(range),\n          placeholder: tagLabel,\n        };\n      }\n    }\n\n    throw new Error('Cannot rename: tag range not found');\n  }\n\n  /**\n   * Generate workspace edits to perform the tag rename operation.\n   *\n   * This method is called after the user enters a new name in the rename dialog.\n   * It validates the new name and generates all necessary text edits across the\n   * entire workspace to rename every occurrence of the tag consistently.\n   *\n   * @param document The VS Code text document where rename was initiated\n   * @param position The original cursor position where F2 was pressed\n   * @param newName The new tag name entered by the user (may include # prefix)\n   * @param token Cancellation token for the operation\n   * @returns WorkspaceEdit containing all necessary changes across files\n   * @throws Error if tag validation fails or rename operation cannot be completed\n   */\n  provideRenameEdits(\n    document: vscode.TextDocument,\n    position: vscode.Position,\n    newName: string,\n    token: vscode.CancellationToken\n  ): vscode.ProviderResult<vscode.WorkspaceEdit> {\n    const fileUri = fromVsCodeUri(document.uri);\n    const foamPosition = Position.create(position.line, position.character);\n    const oldTagLabel = TagEdit.getTagAtPosition(\n      this.foam.tags,\n      fileUri,\n      foamPosition\n    );\n\n    if (!oldTagLabel) {\n      throw new Error('Cannot rename: cursor is not on a tag');\n    }\n\n    // Clean the new name (remove # if user included it)\n    const cleanNewName = newName.startsWith('#')\n      ? newName.substring(1)\n      : newName;\n\n    // Validate the rename\n    const validation = TagEdit.validateTagRename(\n      this.foam.tags,\n      oldTagLabel,\n      cleanNewName\n    );\n\n    if (!validation.isValid) {\n      throw new Error(validation.message);\n    }\n\n    // For F2 rename, we don't support merge confirmation dialogs\n    // Direct users to use the command instead\n    if (validation.isMerge) {\n      throw new Error(\n        `Tag \"${cleanNewName}\" already exists. Use \"Foam: Rename Tag\" command to merge tags.`\n      );\n    }\n\n    try {\n      // Generate all the edits\n      const tagEditResult = TagEdit.createRenameTagEdits(\n        this.foam.tags,\n        oldTagLabel,\n        cleanNewName\n      );\n\n      // Convert to VS Code WorkspaceEdit\n      const workspaceEdit = toVsCodeWorkspaceEdit(\n        tagEditResult.edits,\n        this.foam.workspace\n      );\n\n      Logger.info(\n        `Renaming tag \"${oldTagLabel}\" to \"${cleanNewName}\" (${tagEditResult.totalOccurrences} occurrences)`\n      );\n\n      return workspaceEdit;\n    } catch (error) {\n      Logger.error('Error during tag rename operation:', error);\n      throw new Error(`Failed to rename tag: ${error.message}`);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/features/wikilink-diagnostics.spec.ts",
    "content": "import * as vscode from 'vscode';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport {\n  cleanWorkspace,\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport { toVsCodeUri } from '../utils/vsc-utils';\nimport { updateDiagnostics, IdentifierResolver } from './wikilink-diagnostics';\n\ndescribe('Wikilink diagnostics', () => {\n  beforeEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n  it('should show no warnings when there are no conflicts', async () => {\n    const fileA = await createFile('This is the todo file');\n    const fileB = await createFile(`This is linked to [[${fileA.name}]]`);\n\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should show no warnings in non-md files', async () => {\n    const fileA = await createFile('This is the todo file', [\n      'project',\n      'car',\n      'todo.md',\n    ]);\n    const fileB = await createFile('This is the todo file', [\n      'another',\n      'todo.md',\n    ]);\n    const fileC = await createFile('Link in JS file to [[todo]]', [\n      'path',\n      'file.js',\n    ]);\n\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content))\n      .set(parser.parse(fileC.uri, fileC.content));\n\n    await showInEditor(fileC.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should show a warning when a link cannot be resolved', async () => {\n    const fileA = await createFile('This is the todo file', [\n      'project',\n      'car',\n      'todo.md',\n    ]);\n    const fileB = await createFile('This is the todo file', [\n      'another',\n      'todo.md',\n    ]);\n    const fileC = await createFile('Link to [[todo]]');\n\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content))\n      .set(parser.parse(fileC.uri, fileC.content));\n\n    await showInEditor(fileC.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(1);\n    const items = collection.get(vscode.window.activeTextEditor.document.uri);\n    expect(items.length).toEqual(1);\n    expect(items[0].range).toEqual(new vscode.Range(0, 8, 0, 16));\n    expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);\n    expect(\n      items[0].relatedInformation.map(info => info.location.uri.path)\n    ).toEqual([fileB.uri.path, fileA.uri.path]);\n  });\n});\n\ndescribe('Section diagnostics', () => {\n  it('should show nothing on placeholders', async () => {\n    const file = await createFile('Link to [[placeholder]]');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n  it('should show nothing when the section is correct', async () => {\n    const fileA = await createFile(\n      `\n# Section 1\nContent of section 1\n\n# Section 2\nContent of section 2\n`,\n      ['my-file.md']\n    );\n    const fileB = await createFile('Link to [[my-file#Section 1]]');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n  it('should show a warning when the section name is incorrect', async () => {\n    const fileA = await createFile(\n      `\n# Section 1\nContent of section 1\n\n# Section 2\nContent of section 2\n`\n    );\n    const fileB = await createFile(`Link to [[${fileA.name}#Section 10]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(1);\n    const items = collection.get(toVsCodeUri(fileB.uri));\n    expect(items[0].range).toEqual(new vscode.Range(0, 15, 0, 26));\n    expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);\n    expect(items[0].relatedInformation.map(info => info.message)).toEqual([\n      'Section 1',\n      'Section 2',\n    ]);\n  });\n});\n\ndescribe('Block diagnostics', () => {\n  it('should show no warning when block anchor exists', async () => {\n    const fileA = await createFile('A paragraph ^myblock', [\n      'note-with-block.md',\n    ]);\n    const fileB = await createFile(`Link to [[${fileA.name}#^myblock]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should show a warning when block anchor does not exist', async () => {\n    const fileA = await createFile('A paragraph ^existing', [\n      'note-for-block-diag.md',\n    ]);\n    const fileB = await createFile(`Link to [[${fileA.name}#^ghost]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(1);\n    const items = collection.get(toVsCodeUri(fileB.uri));\n    expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);\n    expect(items[0].relatedInformation.map(info => info.message)).toEqual([\n      '^existing',\n    ]);\n  });\n\n  it('should show nothing on placeholders with block anchors', async () => {\n    const file = await createFile('Link to [[nonexistent#^ghost]]');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    // No diagnostic when the note itself doesn't exist (placeholder)\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should generate a quick-fix that preserves the # when correcting a block anchor', async () => {\n    const fileA = await createFile('A paragraph ^existing', [\n      'note-for-quickfix.md',\n    ]);\n    const fileB = await createFile(`Link to [[${fileA.name}#^ghost]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n\n    const diagnostics = collection.get(toVsCodeUri(fileB.uri));\n    expect(diagnostics).toHaveLength(1);\n\n    const resolver = new IdentifierResolver('.md');\n    const actions = resolver.provideCodeActions(\n      vscode.window.activeTextEditor.document,\n      diagnostics[0].range,\n      { diagnostics, only: null } as vscode.CodeActionContext,\n      null\n    );\n\n    expect(actions).toHaveLength(1);\n    const editArgs = actions[0].command.arguments[0];\n    // The value to replace with should be the block ID including ^\n    expect(editArgs.value).toBe('^existing');\n    // The replacement range should start AFTER the # (at the ^ character)\n    // and end BEFORE the closing ]]\n    const hashPos = `Link to [[${fileA.name}`.length;\n    expect(editArgs.range.start.character).toBe(hashPos + 1); // after #\n    expect(editArgs.range.end.character).toBe(\n      `Link to [[${fileA.name}#^ghost]]`.length - 2\n    ); // before ]]\n\n    await vscode.window.activeTextEditor.edit(builder => {\n      builder.replace(editArgs.range, editArgs.value);\n    });\n\n    expect(vscode.window.activeTextEditor.document.getText()).toBe(\n      `Link to [[${fileA.name}#^existing]]`\n    );\n  });\n\n  it('should preserve the # when correcting a block anchor in an escaped wikilink target', async () => {\n    const fileA = await createFile('A paragraph ^existing', [\n      'Note with spaces.md',\n    ]);\n    const escapedTarget = 'Note\\\\ with\\\\ spaces';\n    const fileB = await createFile(`Link to [[${escapedTarget}#^ghost]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n\n    const diagnostics = collection.get(toVsCodeUri(fileB.uri));\n    expect(diagnostics).toHaveLength(1);\n\n    const resolver = new IdentifierResolver('.md');\n    const actions = resolver.provideCodeActions(\n      vscode.window.activeTextEditor.document,\n      diagnostics[0].range,\n      { diagnostics, only: null } as vscode.CodeActionContext,\n      null\n    );\n\n    expect(actions).toHaveLength(1);\n    const editArgs = actions[0].command.arguments[0];\n\n    await vscode.window.activeTextEditor.edit(builder => {\n      builder.replace(editArgs.range, editArgs.value);\n    });\n\n    expect(vscode.window.activeTextEditor.document.getText()).toBe(\n      `Link to [[${escapedTarget}#^existing]]`\n    );\n  });\n\n  it('should preserve the alias when correcting a block anchor', async () => {\n    const fileA = await createFile('A paragraph ^existing', [\n      'note-for-alias-quickfix.md',\n    ]);\n    const fileB = await createFile(`Link to [[${fileA.name}#^ghost|My Label]]`);\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace()\n      .set(parser.parse(fileA.uri, fileA.content))\n      .set(parser.parse(fileB.uri, fileB.content));\n\n    await showInEditor(fileB.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n\n    const diagnostics = collection.get(toVsCodeUri(fileB.uri));\n    expect(diagnostics).toHaveLength(1);\n\n    const resolver = new IdentifierResolver('.md');\n    const actions = resolver.provideCodeActions(\n      vscode.window.activeTextEditor.document,\n      diagnostics[0].range,\n      { diagnostics, only: null } as vscode.CodeActionContext,\n      null\n    );\n\n    expect(actions).toHaveLength(1);\n    const editArgs = actions[0].command.arguments[0];\n\n    await vscode.window.activeTextEditor.edit(builder => {\n      builder.replace(editArgs.range, editArgs.value);\n    });\n\n    expect(vscode.window.activeTextEditor.document.getText()).toBe(\n      `Link to [[${fileA.name}#^existing|My Label]]`\n    );\n  });\n});\n\ndescribe('Duplicate block ID diagnostics', () => {\n  beforeEach(async () => {\n    await cleanWorkspace();\n    await closeEditors();\n  });\n\n  it('should show no warning when all block IDs in a file are unique', async () => {\n    const file = await createFile('Para one ^block1\\n\\nPara two ^block2\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should warn only on the duplicate (2nd+) occurrence, not the first', async () => {\n    const file = await createFile('Para one ^myblock\\n\\nPara two ^myblock\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    const items = collection.get(vscode.window.activeTextEditor.document.uri);\n    // Only the 2nd occurrence is flagged\n    expect(items).toHaveLength(1);\n    expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);\n    // Related info points to the first occurrence\n    expect(items[0].relatedInformation).toHaveLength(1);\n  });\n\n  it('should highlight the ^id text on the duplicate line', async () => {\n    const file = await createFile('First ^dup\\n\\nSecond ^dup\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    const items = collection.get(vscode.window.activeTextEditor.document.uri);\n    expect(items).toHaveLength(1);\n    // Duplicate is the second paragraph (line 2)\n    expect(items[0].range.start.line).toBe(2);\n    // Range covers '^dup' (4 chars)\n    expect(items[0].range.end.character - items[0].range.start.character).toBe(\n      4\n    );\n  });\n\n  it('should not show a warning for a list item with a unique block ID', async () => {\n    const file = await createFile('- Item one ^listblock\\n- Item two\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should not show a warning for a list item with nested subitems', async () => {\n    const file = await createFile('- this is item ^listblock\\n  - subitem\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n\n  it('should offer a \"Replace with new ID\" quick fix for each duplicate', async () => {\n    const file = await createFile('Para one ^dup\\n\\nPara two ^dup\\n');\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    const diagnostics = Array.from(\n      collection.get(vscode.window.activeTextEditor.document.uri) ?? []\n    );\n    expect(diagnostics).toHaveLength(1);\n\n    const resolver = new IdentifierResolver('.md');\n    const actions = resolver.provideCodeActions(\n      vscode.window.activeTextEditor.document,\n      diagnostics[0].range,\n      { diagnostics, only: null } as unknown as vscode.CodeActionContext,\n      null\n    );\n\n    expect(actions).toHaveLength(1);\n    expect(actions[0].title).toBe('Replace with new ID');\n    expect(actions[0].command.arguments[0].value).toMatch(/^\\^[a-z0-9]+$/);\n  });\n\n  it('should warn about duplicate block IDs on list items that have nested subitems', async () => {\n    // The ^id anchor appears on the list item's start line (line 0), while the\n    // block range.end points to the last nested subitem (line 1). The diagnostic\n    // must still find the anchor and highlight it on the correct line.\n    const file = await createFile(\n      '- first item ^dup\\n  - subitem\\n\\n- second item ^dup\\n'\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    const items = collection.get(vscode.window.activeTextEditor.document.uri);\n    // Only the 2nd occurrence should be flagged\n    expect(items).toHaveLength(1);\n    expect(items[0].severity).toEqual(vscode.DiagnosticSeverity.Warning);\n    // The duplicate is the second list item (line 3, 0-based)\n    expect(items[0].range.start.line).toBe(3);\n    // Range covers '^dup' (4 chars)\n    expect(items[0].range.end.character - items[0].range.start.character).toBe(\n      4\n    );\n  });\n\n  it('should not flag blocks when only one occurrence exists', async () => {\n    const file = await createFile(\n      'Para one ^alpha\\n\\nPara two ^beta\\n\\nPara three ^alpha-variant\\n'\n    );\n    const parser = createMarkdownParser([]);\n    const ws = new FoamWorkspace().set(parser.parse(file.uri, file.content));\n\n    await showInEditor(file.uri);\n\n    const collection = vscode.languages.createDiagnosticCollection('foam-test');\n    updateDiagnostics(\n      ws,\n      parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n    expect(countEntries(collection)).toEqual(0);\n  });\n});\n\nconst countEntries = (collection: vscode.DiagnosticCollection): number => {\n  let count = 0;\n  collection.forEach((i, diagnostics) => {\n    count += diagnostics.length;\n  });\n  return count;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/features/wikilink-diagnostics.ts",
    "content": "import { debounce } from 'lodash';\nimport * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport {\n  Block,\n  Resource,\n  ResourceLink,\n  ResourceParser,\n} from '../core/model/note';\nimport { Range } from '../core/model/range';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { MarkdownLink } from '../core/services/markdown-link';\nimport {\n  fromVsCodeUri,\n  toVsCodePosition,\n  toVsCodeRange,\n  toVsCodeUri,\n} from '../utils/vsc-utils';\nimport { isNone } from '../core/utils';\n\nconst AMBIGUOUS_IDENTIFIER_CODE = 'ambiguous-identifier';\nconst UNKNOWN_SECTION_CODE = 'unknown-section';\nconst UNKNOWN_BLOCK_CODE = 'unknown-block';\nconst DUPLICATE_BLOCK_ID_CODE = 'duplicate-block-id';\n\ninterface FoamCommand<T> {\n  name: string;\n  execute: (params: T) => Promise<void>;\n}\n\ninterface FindIdentifierCommandArgs {\n  range: vscode.Range;\n  target: vscode.Uri;\n  defaultExtension: string;\n  amongst: vscode.Uri[];\n}\n\nconst FIND_IDENTIFIER_COMMAND: FoamCommand<FindIdentifierCommandArgs> = {\n  name: 'foam:compute-identifier',\n  execute: async ({ target, amongst, range, defaultExtension }) => {\n    if (vscode.window.activeTextEditor) {\n      let identifier = FoamWorkspace.getShortestIdentifier(\n        target.path,\n        amongst.map(uri => uri.path)\n      );\n\n      identifier = identifier.endsWith(defaultExtension)\n        ? identifier.slice(0, defaultExtension.length * -1)\n        : identifier;\n\n      await vscode.window.activeTextEditor.edit(builder => {\n        builder.replace(range, identifier);\n      });\n    }\n  },\n};\n\ninterface ReplaceTextCommandArgs {\n  range: vscode.Range;\n  value: string;\n}\n\nconst REPLACE_TEXT_COMMAND: FoamCommand<ReplaceTextCommandArgs> = {\n  name: 'foam:replace-text',\n  execute: async ({ range, value }) => {\n    await vscode.window.activeTextEditor.edit(builder => {\n      builder.replace(range, value);\n    });\n  },\n};\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const collection = vscode.languages.createDiagnosticCollection('foam');\n  const debouncedUpdateDiagnostics = debounce(updateDiagnostics, 500);\n  const foam = await foamPromise;\n  if (vscode.window.activeTextEditor) {\n    updateDiagnostics(\n      foam.workspace,\n      foam.services.parser,\n      vscode.window.activeTextEditor.document,\n      collection\n    );\n  }\n  context.subscriptions.push(\n    vscode.window.onDidChangeActiveTextEditor(editor => {\n      if (editor) {\n        updateDiagnostics(\n          foam.workspace,\n          foam.services.parser,\n          editor.document,\n          collection\n        );\n      }\n    }),\n    vscode.workspace.onDidChangeTextDocument(event => {\n      debouncedUpdateDiagnostics(\n        foam.workspace,\n        foam.services.parser,\n        event.document,\n        collection\n      );\n    }),\n    vscode.languages.registerCodeActionsProvider(\n      'markdown',\n      new IdentifierResolver(foam.workspace.defaultExtension),\n      {\n        providedCodeActionKinds: IdentifierResolver.providedCodeActionKinds,\n      }\n    ),\n    vscode.commands.registerCommand(\n      FIND_IDENTIFIER_COMMAND.name,\n      FIND_IDENTIFIER_COMMAND.execute\n    ),\n    vscode.commands.registerCommand(\n      REPLACE_TEXT_COMMAND.name,\n      REPLACE_TEXT_COMMAND.execute\n    )\n  );\n}\n\nexport function updateDiagnostics(\n  workspace: FoamWorkspace,\n  parser: ResourceParser,\n  document: vscode.TextDocument,\n  collection: vscode.DiagnosticCollection\n): void {\n  collection.clear();\n  const result = [];\n  if (document && document.languageId === 'markdown') {\n    const resource = parser.parse(\n      fromVsCodeUri(document.uri),\n      document.getText()\n    );\n\n    for (const link of resource.links) {\n      if (link.type === 'wikilink') {\n        const { target, section, blockId } = MarkdownLink.analyzeLink(link);\n        const targets = workspace.listByIdentifier(target);\n        if (targets.length > 1) {\n          result.push({\n            code: AMBIGUOUS_IDENTIFIER_CODE,\n            message: 'Resource identifier is ambiguous',\n            range: toVsCodeRange(link.range),\n            severity: vscode.DiagnosticSeverity.Warning,\n            source: 'Foam',\n            relatedInformation: targets.map(\n              t =>\n                new vscode.DiagnosticRelatedInformation(\n                  new vscode.Location(\n                    toVsCodeUri(t.uri),\n                    new vscode.Position(0, 0)\n                  ),\n                  `Possible target: ${vscode.workspace.asRelativePath(\n                    toVsCodeUri(t.uri)\n                  )}`\n                )\n            ),\n          });\n        }\n        if (section && targets.length === 1) {\n          const resource = targets[0];\n          if (isNone(Resource.findSection(resource, section))) {\n            const range = getFragmentDiagnosticRange(link, section);\n            result.push({\n              code: UNKNOWN_SECTION_CODE,\n              message: `Cannot find section \"${section}\" in document, available sections are:`,\n              range: toVsCodeRange(range),\n              severity: vscode.DiagnosticSeverity.Warning,\n              source: 'Foam',\n              relatedInformation: resource.sections.map(\n                b =>\n                  new vscode.DiagnosticRelatedInformation(\n                    new vscode.Location(\n                      toVsCodeUri(resource.uri),\n                      toVsCodePosition(b.range.start)\n                    ),\n                    b.label\n                  )\n              ),\n            });\n          }\n        }\n        if (blockId && targets.length === 1) {\n          const resource = targets[0];\n          if (isNone(Resource.findBlock(resource, blockId))) {\n            const range = getFragmentDiagnosticRange(link, `^${blockId}`);\n            result.push({\n              code: UNKNOWN_BLOCK_CODE,\n              message: `Cannot find block \"^${blockId}\" in document, available blocks are:`,\n              range: toVsCodeRange(range),\n              severity: vscode.DiagnosticSeverity.Warning,\n              source: 'Foam',\n              relatedInformation: resource.blocks.map(\n                b =>\n                  new vscode.DiagnosticRelatedInformation(\n                    new vscode.Location(\n                      toVsCodeUri(resource.uri),\n                      toVsCodeRange(b.markerRange)\n                    ),\n                    `^${b.id}`\n                  )\n              ),\n            });\n          }\n        }\n      }\n    }\n    // Detect duplicate block IDs within this document\n    const blocksByID = new Map<string, typeof resource.blocks>();\n    for (const block of resource.blocks) {\n      if (!blocksByID.has(block.id)) {\n        blocksByID.set(block.id, []);\n      }\n      blocksByID.get(block.id)!.push(block);\n    }\n    for (const [id, blocks] of blocksByID) {\n      if (blocks.length < 2) {\n        continue;\n      }\n      // Only flag the duplicates (2nd occurrence onwards); the first is fine.\n      for (const block of blocks.slice(1)) {\n        result.push({\n          code: DUPLICATE_BLOCK_ID_CODE,\n          message: `Duplicate block ID \"^${id}\" - ignored`,\n          range: toVsCodeRange(block.markerRange),\n          severity: vscode.DiagnosticSeverity.Warning,\n          source: 'Foam',\n          relatedInformation: blocks\n            .filter(b => b !== block)\n            .map(\n              b =>\n                new vscode.DiagnosticRelatedInformation(\n                  new vscode.Location(\n                    document.uri,\n                    toVsCodeRange(b.markerRange)\n                  ),\n                  `Other occurrence of \"^${id}\"`\n                )\n            ),\n        });\n      }\n    }\n\n    if (result.length > 0) {\n      collection.set(document.uri, result);\n    }\n  }\n}\n\nexport class IdentifierResolver implements vscode.CodeActionProvider {\n  public static readonly providedCodeActionKinds = [\n    vscode.CodeActionKind.QuickFix,\n  ];\n\n  constructor(private defaultExtension: string) {}\n\n  provideCodeActions(\n    document: vscode.TextDocument,\n    range: vscode.Range | vscode.Selection,\n    context: vscode.CodeActionContext,\n    token: vscode.CancellationToken\n  ): vscode.CodeAction[] {\n    return context.diagnostics.reduce((acc, diagnostic) => {\n      if (diagnostic.code === AMBIGUOUS_IDENTIFIER_CODE) {\n        const res: vscode.CodeAction[] = [];\n        const uris = diagnostic.relatedInformation.map(\n          info => info.location.uri\n        );\n        for (const item of diagnostic.relatedInformation) {\n          res.push(\n            createFindIdentifierCommand(\n              diagnostic,\n              item.location.uri,\n              this.defaultExtension,\n              uris\n            )\n          );\n        }\n        return [...acc, ...res];\n      }\n      if (diagnostic.code === UNKNOWN_SECTION_CODE) {\n        const res: vscode.CodeAction[] = [];\n        const sections = diagnostic.relatedInformation.map(\n          info => info.message\n        );\n        for (const section of sections) {\n          res.push(createReplaceSectionCommand(diagnostic, section));\n        }\n        return [...acc, ...res];\n      }\n      if (diagnostic.code === UNKNOWN_BLOCK_CODE) {\n        const res: vscode.CodeAction[] = [];\n        const blockIds = diagnostic.relatedInformation.map(\n          info => info.message\n        );\n        for (const blockId of blockIds) {\n          res.push(createReplaceBlockCommand(diagnostic, blockId));\n        }\n        return [...acc, ...res];\n      }\n      if (diagnostic.code === DUPLICATE_BLOCK_ID_CODE) {\n        return [...acc, createReplaceBlockIdCommand(diagnostic)];\n      }\n      return acc;\n    }, [] as vscode.CodeAction[]);\n  }\n}\n\nconst createReplaceSectionCommand = (\n  diagnostic: vscode.Diagnostic,\n  section: string\n): vscode.CodeAction => {\n  const action = new vscode.CodeAction(\n    `${section}`,\n    vscode.CodeActionKind.QuickFix\n  );\n  action.command = {\n    command: REPLACE_TEXT_COMMAND.name,\n    title: `Use section \"${section}\"`,\n    arguments: [\n      {\n        value: section,\n        range: fragmentValueRange(diagnostic.range),\n      },\n    ],\n  };\n  action.diagnostics = [diagnostic];\n  return action;\n};\n\nconst createReplaceBlockCommand = (\n  diagnostic: vscode.Diagnostic,\n  blockId: string\n): vscode.CodeAction => {\n  const action = new vscode.CodeAction(\n    `${blockId}`,\n    vscode.CodeActionKind.QuickFix\n  );\n  action.command = {\n    command: REPLACE_TEXT_COMMAND.name,\n    title: `Use block \"${blockId}\"`,\n    arguments: [\n      {\n        value: blockId,\n        range: fragmentValueRange(diagnostic.range),\n      },\n    ],\n  };\n  action.diagnostics = [diagnostic];\n  return action;\n};\n\n/**\n * Returns the range covering `#fragment` in a wikilink. The range starts at\n * `#` and ends immediately after the fragment text, before any alias `|` or\n * closing `]]`. The caller supplies the already-parsed `fragment` string\n * (e.g. `\"Section 1\"` or `\"^blockid\"`), so no secondary rawText scanning is\n * needed.\n */\nconst getFragmentDiagnosticRange = (\n  link: ResourceLink,\n  fragment: string\n): Range => {\n  const hashPos = link.rawText.indexOf('#');\n  if (hashPos < 0) {\n    // No fragment — degenerate range at the link end\n    return Range.create(\n      link.range.end.line,\n      link.range.end.character,\n      link.range.end.line,\n      link.range.end.character\n    );\n  }\n\n  return Range.create(\n    link.range.start.line,\n    link.range.start.character + hashPos,\n    link.range.end.line,\n    link.range.start.character + hashPos + 1 + fragment.length\n  );\n};\n\n/**\n * Given a diagnostic range that starts at `#` and ends just before `|` or\n * `]]` (as guaranteed by `getFragmentDiagnosticRange`), return the range\n * covering only the fragment value — i.e. everything after the `#`.\n */\nconst fragmentValueRange = (diagnosticRange: vscode.Range): vscode.Range =>\n  new vscode.Range(\n    diagnosticRange.start.line,\n    diagnosticRange.start.character + 1,\n    diagnosticRange.end.line,\n    diagnosticRange.end.character\n  );\n\nconst createReplaceBlockIdCommand = (\n  diagnostic: vscode.Diagnostic\n): vscode.CodeAction => {\n  const newId = Block.generateId();\n  const action = new vscode.CodeAction(\n    'Replace with new ID',\n    vscode.CodeActionKind.QuickFix\n  );\n  action.command = {\n    command: REPLACE_TEXT_COMMAND.name,\n    title: 'Replace with new ID',\n    arguments: [\n      {\n        value: '^' + newId,\n        range: diagnostic.range,\n      },\n    ],\n  };\n  action.diagnostics = [diagnostic];\n  return action;\n};\n\nconst createFindIdentifierCommand = (\n  diagnostic: vscode.Diagnostic,\n  target: vscode.Uri,\n  defaultExtension: string,\n  possibleTargets: vscode.Uri[]\n): vscode.CodeAction => {\n  const action = new vscode.CodeAction(\n    `${vscode.workspace.asRelativePath(target)}`,\n    vscode.CodeActionKind.QuickFix\n  );\n  action.command = {\n    command: FIND_IDENTIFIER_COMMAND.name,\n    title: 'Link to this resource',\n    arguments: [\n      {\n        target: target,\n        amongst: possibleTargets,\n        defaultExtension: defaultExtension,\n        range: new vscode.Range(\n          diagnostic.range.start.line,\n          diagnostic.range.start.character + 2,\n          diagnostic.range.end.line,\n          diagnostic.range.end.character - 2\n        ),\n      },\n    ],\n  };\n  action.diagnostics = [diagnostic];\n  return action;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/features/workspace-symbol-provider.spec.ts",
    "content": "/* @unit-ready */\n\nimport * as vscode from 'vscode';\nimport { FoamWorkspaceSymbolProvider } from './workspace-symbol-provider';\nimport { createTestNote, createTestWorkspace } from '../test/test-utils';\n\ndescribe('FoamWorkspaceSymbolProvider Integration', () => {\n  let provider: FoamWorkspaceSymbolProvider;\n  let workspace: any;\n\n  beforeEach(async () => {\n    workspace = createTestWorkspace();\n    provider = new FoamWorkspaceSymbolProvider(workspace);\n  });\n\n  it('should integrate with VS Code workspace symbol search', async () => {\n    // Create test notes with aliases\n    const note1 = createTestNote({\n      uri: '/test1.md',\n      aliases: ['first alternative'],\n    });\n\n    const note2 = createTestNote({\n      uri: '/test2.md',\n      aliases: ['second alternative'],\n    });\n\n    workspace.set(note1);\n    workspace.set(note2);\n\n    // Test the provider directly (simulating VS Code's call)\n    const symbols = provider.provideWorkspaceSymbols('alt');\n\n    expect(symbols).toHaveLength(2);\n\n    const symbolNames = symbols.map(s => s.name);\n    expect(symbolNames).toContain('first alternative');\n    expect(symbolNames).toContain('second alternative');\n\n    // Verify symbol properties match VS Code expectations\n    symbols.forEach(symbol => {\n      expect(symbol).toBeInstanceOf(vscode.SymbolInformation);\n      expect(symbol.kind).toBe(vscode.SymbolKind.String);\n      expect(symbol.location).toBeInstanceOf(vscode.Location);\n      expect(symbol.location.uri).toBeDefined();\n      expect(symbol.location.range).toBeInstanceOf(vscode.Range);\n    });\n  });\n\n  it('should handle real-world alias formats from frontmatter', async () => {\n    // Test with array format aliases\n    const noteWithArrayAliases = createTestNote({\n      uri: '/array-aliases.md',\n      aliases: ['alias one', 'alias two'],\n    });\n\n    // Test with comma-separated format aliases\n    const noteWithCommaSeparated = createTestNote({\n      uri: '/comma-aliases.md',\n      aliases: ['first, second, third'],\n    });\n\n    workspace.set(noteWithArrayAliases);\n    workspace.set(noteWithCommaSeparated);\n\n    // Test searching for different parts\n    const aliasOneResults = provider.provideWorkspaceSymbols('one');\n    expect(aliasOneResults).toHaveLength(1);\n    expect(aliasOneResults[0].name).toBe('alias one');\n\n    const commaResults = provider.provideWorkspaceSymbols('first');\n    expect(commaResults).toHaveLength(1);\n    expect(commaResults[0].name).toBe('first, second, third');\n  });\n\n  it('should provide location information for navigation', async () => {\n    const note = createTestNote({\n      uri: '/location-test.md',\n      aliases: ['test alias'],\n    });\n\n    workspace.set(note);\n\n    const symbols = provider.provideWorkspaceSymbols('test');\n    expect(symbols).toHaveLength(1);\n\n    const symbol = symbols[0];\n    // The createTestNote function uses default ranges for aliases\n    expect(symbol.location.range).toBeInstanceOf(vscode.Range);\n    expect(symbol.containerName).toBe('location-test.md');\n  });\n\n  it('should handle large workspace with many aliases efficiently (so we do not need to cache)', async () => {\n    // Create many notes with aliases to test performance\n    for (let i = 0; i < 10000; i++) {\n      const note = createTestNote({\n        uri: `/note${i}.md`,\n        aliases: [`alias number ${i}`, `alternative ${i}`],\n      });\n      workspace.set(note);\n    }\n\n    // Performance test - should complete quickly as we have decided not to cache\n    const start = Date.now();\n    const symbols = provider.provideWorkspaceSymbols('alternative');\n    const end = Date.now();\n\n    expect(symbols).toHaveLength(10000);\n    expect(end - start).toBeLessThan(500); // Should complete in under 500ms\n  });\n\n  it('should not interfere with existing markdown symbols', async () => {\n    // This test verifies that our provider complements VS Code's built-in markdown symbols\n    // rather than replacing them. We can't directly test VS Code's built-in provider,\n    // but we can ensure our provider only returns aliases.\n\n    const note = createTestNote({\n      uri: '/mixed-content.md',\n      title: 'Main Title',\n      aliases: ['only alias here'],\n      sections: ['Section Heading'],\n    });\n\n    workspace.set(note);\n\n    // Our provider should only return aliases, not sections or titles\n    const symbols = provider.provideWorkspaceSymbols('');\n    expect(symbols).toHaveLength(1);\n    expect(symbols[0].name).toBe('only alias here');\n    expect(symbols[0].kind).toBe(vscode.SymbolKind.String);\n\n    // Should not return sections (those are handled by VS Code's markdown provider)\n    expect(symbols.find(s => s.name === 'Section Heading')).toBeUndefined();\n    expect(symbols.find(s => s.name === 'Main Title')).toBeUndefined();\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/workspace-symbol-provider.test.ts",
    "content": "import { FoamWorkspaceSymbolProvider } from './workspace-symbol-provider';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { Resource } from '../core/model/note';\nimport { URI } from '../core/model/uri';\nimport { Range } from '../core/model/range';\nimport * as vscode from 'vscode';\n\ndescribe('FoamWorkspaceSymbolProvider', () => {\n  describe('matchesQuery', () => {\n    it('should match empty query', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n      const result = provider.provideWorkspaceSymbols('');\n      expect(result).toEqual([]);\n    });\n\n    it('should match subsequence in alias title', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n      expect(provider.matchesQuery('alt', 'alternative title')).toBe(true);\n      expect(provider.matchesQuery('altit', 'alternative title')).toBe(true);\n      expect(provider.matchesQuery('title', 'alternative title')).toBe(true);\n      expect(provider.matchesQuery('tit', 'alternative title')).toBe(true);\n    });\n\n    it('should not match wrong order', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n\n      expect(provider.matchesQuery('title alt', 'alternative title')).toBe(\n        false\n      );\n      expect(provider.matchesQuery('zyx', 'alternative title')).toBe(false);\n    });\n\n    it('should be case insensitive', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n\n      expect(provider.matchesQuery('ALT', 'alternative title')).toBe(true);\n      expect(provider.matchesQuery('alt', 'ALTERNATIVE TITLE')).toBe(true);\n      expect(provider.matchesQuery('AlT', 'Alternative Title')).toBe(true);\n    });\n\n    it('should match exact strings', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n\n      expect(\n        provider.matchesQuery('alternative title', 'alternative title')\n      ).toBe(true);\n      expect(provider.matchesQuery('', 'alternative title')).toBe(true);\n    });\n  });\n\n  describe('provideWorkspaceSymbols', () => {\n    it('should return empty array when workspace is empty', () => {\n      const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());\n\n      const result = provider.provideWorkspaceSymbols('test');\n      expect(result).toEqual([]);\n    });\n\n    it('should return empty array when no aliases match', () => {\n      const workspace = new FoamWorkspace();\n      const provider = new FoamWorkspaceSymbolProvider(workspace);\n\n      const resource: Resource = {\n        uri: URI.file('/test.md'),\n        type: 'note',\n        title: 'Test Note',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'different alias',\n            range: Range.create(0, 0, 0, 10),\n          },\n        ],\n        links: [],\n      };\n      workspace.set(resource);\n\n      const result = provider.provideWorkspaceSymbols('notfound');\n      expect(result).toEqual([]);\n    });\n\n    it('should return matching aliases from single resource', () => {\n      const workspace = new FoamWorkspace();\n      const provider = new FoamWorkspaceSymbolProvider(workspace);\n\n      const aliasRange = Range.create(2, 0, 2, 20);\n      const resource: Resource = {\n        uri: URI.file('/test.md'),\n        type: 'note',\n        title: 'Test Note',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'alternative title',\n            range: aliasRange,\n          },\n          {\n            title: 'another name',\n            range: aliasRange,\n          },\n        ],\n        links: [],\n      };\n      workspace.set(resource);\n\n      const result = provider.provideWorkspaceSymbols('alt');\n      expect(result).toHaveLength(1);\n      expect(result[0].name).toBe('alternative title');\n      expect(result[0].kind).toBe(vscode.SymbolKind.String);\n      expect(result[0].containerName).toBe('test.md');\n    });\n\n    it('should return matching aliases from multiple resources', () => {\n      const workspace = new FoamWorkspace();\n      const provider = new FoamWorkspaceSymbolProvider(workspace);\n\n      const aliasRange = Range.create(2, 0, 2, 20);\n\n      const resource1: Resource = {\n        uri: URI.file('/note1.md'),\n        type: 'note',\n        title: 'Note 1',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'alternative one',\n            range: aliasRange,\n          },\n        ],\n        links: [],\n      };\n\n      const resource2: Resource = {\n        uri: URI.file('/note2.md'),\n        type: 'note',\n        title: 'Note 2',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'alternative two',\n            range: aliasRange,\n          },\n        ],\n        links: [],\n      };\n\n      workspace.set(resource1);\n      workspace.set(resource2);\n\n      const result = provider.provideWorkspaceSymbols('alt');\n      expect(result).toHaveLength(2);\n      expect(result.map(s => s.name)).toContain('alternative one');\n      expect(result.map(s => s.name)).toContain('alternative two');\n      expect(result.map(s => s.containerName)).toContain('note1.md');\n      expect(result.map(s => s.containerName)).toContain('note2.md');\n    });\n\n    it('should return all aliases when query is empty', () => {\n      const workspace = new FoamWorkspace();\n      const provider = new FoamWorkspaceSymbolProvider(workspace);\n\n      const aliasRange = Range.create(2, 0, 2, 20);\n      const resource: Resource = {\n        uri: URI.file('/test.md'),\n        type: 'note',\n        title: 'Test Note',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'first alias',\n            range: aliasRange,\n          },\n          {\n            title: 'second alias',\n            range: aliasRange,\n          },\n        ],\n        links: [],\n      };\n      workspace.set(resource);\n\n      const result = provider.provideWorkspaceSymbols('');\n      expect(result).toHaveLength(2);\n      expect(result.map(s => s.name)).toContain('first alias');\n      expect(result.map(s => s.name)).toContain('second alias');\n    });\n\n    it('should create SymbolInformation with correct properties', () => {\n      const workspace = new FoamWorkspace();\n      const provider = new FoamWorkspaceSymbolProvider(workspace);\n\n      const aliasRange = Range.create(2, 5, 2, 25);\n      const resource: Resource = {\n        uri: URI.file('/path/to/note.md'),\n        type: 'note',\n        title: 'Test Note',\n        properties: {},\n        sections: [],\n        blocks: [],\n        tags: [],\n        aliases: [\n          {\n            title: 'test alias',\n            range: aliasRange,\n          },\n        ],\n        links: [],\n      };\n      workspace.set(resource);\n\n      const result = provider.provideWorkspaceSymbols('test');\n      expect(result).toHaveLength(1);\n\n      const symbol = result[0];\n      expect(symbol.name).toBe('test alias');\n      expect(symbol.kind).toBe(vscode.SymbolKind.String);\n      expect(symbol.containerName).toBe('note.md');\n      expect(symbol.location.uri.toString()).toContain('/path/to/note.md');\n      expect(symbol.location.range.start.line).toBe(2);\n      expect(symbol.location.range.start.character).toBe(5);\n      expect(symbol.location.range.end.line).toBe(2);\n      expect(symbol.location.range.end.character).toBe(25);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/features/workspace-symbol-provider.ts",
    "content": "import * as vscode from 'vscode';\nimport { Foam } from '../core/model/foam';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';\n\nexport default async function activate(\n  context: vscode.ExtensionContext,\n  foamPromise: Promise<Foam>\n) {\n  const foam = await foamPromise;\n\n  const workspaceSymbolProvider = new FoamWorkspaceSymbolProvider(\n    foam.workspace\n  );\n\n  context.subscriptions.push(\n    vscode.languages.registerWorkspaceSymbolProvider(workspaceSymbolProvider)\n  );\n}\n\n/**\n * Provides workspace symbols for note aliases.\n * Allows users to search for notes by their aliases using \"Go To Symbol in Workspace\" (Ctrl+T/Cmd+T).\n */\nexport class FoamWorkspaceSymbolProvider\n  implements vscode.WorkspaceSymbolProvider\n{\n  constructor(private workspace: FoamWorkspace) {}\n\n  /**\n   * Provide workspace symbols for note aliases.\n   * Called every time the user types in the symbol search box.\n   */\n  provideWorkspaceSymbols(query: string): vscode.SymbolInformation[] {\n    return this.workspace\n      .list()\n      .flatMap(resource =>\n        resource.aliases\n          .filter(\n            alias => query === '' || this.matchesQuery(query, alias.title)\n          )\n          .map(\n            alias =>\n              new vscode.SymbolInformation(\n                alias.title,\n                vscode.SymbolKind.String,\n                resource.uri.getBasename(),\n                new vscode.Location(\n                  toVsCodeUri(resource.uri),\n                  toVsCodeRange(alias.range)\n                )\n              )\n          )\n      );\n  }\n\n  /**\n   * Check if a candidate string matches a query using subsequence matching.\n   * Characters of query must appear in their order in the candidate (case-insensitive).\n   * This follows VS Code's recommended approach for symbol providers.\n   *\n   * Examples:\n   * - \"alt\" matches \"alternative title\"\n   * - \"altit\" matches \"alternative title\"\n   * - \"title alt\" does not match \"alternative title\" (wrong order)\n   */\n  matchesQuery(query: string, candidate: string): boolean {\n    const queryLower = query.toLowerCase();\n    const candidateLower = candidate.toLowerCase();\n\n    let queryIndex = 0;\n    for (\n      let i = 0;\n      i < candidateLower.length && queryIndex < queryLower.length;\n      i++\n    ) {\n      if (candidateLower[i] === queryLower[queryIndex]) {\n        queryIndex++;\n      }\n    }\n    return queryIndex === queryLower.length;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/cache.ts",
    "content": "import { debounce } from 'lodash';\nimport LRU from 'lru-cache';\nimport { ExtensionContext } from 'vscode';\nimport { URI } from '../core/model/uri';\nimport {\n  ParserCache,\n  ParserCacheEntry,\n} from '../core/services/markdown-parser';\nimport { Logger } from '../core/utils/log';\n\n/**\n * This is a best effort implementation to cache resources.\n * It's not a perfect solution, but it's a good start.\n *\n * We use the URI and a checksum of the markdown file to cache the resource.\n *\n * Bump CACHE_VERSION whenever the cached Resource schema changes (e.g. new\n * fields added to Block, ResourceLink, etc.) so stale persisted entries are\n * discarded automatically on the next startup.\n */\nexport default class VsCodeBasedParserCache implements ParserCache {\n  static CACHE_NAME = 'foam-cache';\n  static CACHE_VERSION = 2;\n  static CACHE_VERSION_KEY = 'foam-cache-version';\n  private _cache: LRU<string, ParserCacheEntry>;\n\n  constructor(private context: ExtensionContext, size = 10000) {\n    this._cache = new LRU({\n      max: size,\n      updateAgeOnGet: true,\n      updateAgeOnHas: false,\n    });\n\n    const storedVersion = context.workspaceState.get<number>(\n      VsCodeBasedParserCache.CACHE_VERSION_KEY,\n      0\n    );\n    if (storedVersion !== VsCodeBasedParserCache.CACHE_VERSION) {\n      Logger.debug(\n        `Cache version mismatch (stored: ${storedVersion}, current: ${VsCodeBasedParserCache.CACHE_VERSION}) — clearing cache`\n      );\n      this.clear();\n      context.workspaceState.update(\n        VsCodeBasedParserCache.CACHE_VERSION_KEY,\n        VsCodeBasedParserCache.CACHE_VERSION\n      );\n    } else {\n      const source = context.workspaceState.get(\n        VsCodeBasedParserCache.CACHE_NAME,\n        []\n      );\n      try {\n        this._cache.load(source);\n      } catch (e) {\n        Logger.warn(`Failed to load cache: ${e}`);\n        this.clear();\n      }\n    }\n    Logger.debug('Cache size: ' + this._cache.size);\n  }\n\n  clear(): void {\n    this._cache.clear();\n    this.context.workspaceState.update(VsCodeBasedParserCache.CACHE_NAME, []);\n  }\n\n  get(uri: URI): ParserCacheEntry {\n    const result = this._cache.get(uri.toString());\n    if (result) {\n      // The cache returns a plain object, but we need an actual\n      // instance of URI in the resource (we check instanceof in the code),\n      // so to be sure we convert it here.\n      const { checksum, resource } = result;\n      const rehydrated = {\n        ...resource,\n        uri: new URI(resource.uri),\n      };\n      return {\n        checksum,\n        resource: rehydrated,\n      };\n    }\n    return undefined;\n  }\n\n  has(uri: URI): boolean {\n    return this._cache.has(uri.toString());\n  }\n\n  set(uri: URI, entry: ParserCacheEntry): void {\n    this._cache.set(uri.toString(), entry);\n    delayedSync(this._cache, this.context);\n  }\n\n  del(uri: URI): void {\n    this._cache.delete(uri.toString());\n    delayedSync(this._cache, this.context);\n  }\n}\n\nconst delayedSync = debounce(\n  (cache: LRU<string, ParserCacheEntry>, context) => {\n    Logger.debug('Updating parser cache');\n    context.workspaceState.update(\n      VsCodeBasedParserCache.CACHE_NAME,\n      cache.dump()\n    );\n  },\n  1000\n);\n"
  },
  {
    "path": "packages/foam-vscode/src/services/config.ts",
    "content": "import { Disposable, workspace } from 'vscode';\n\nexport interface ConfigurationMonitor<T> extends Disposable {\n  (): T;\n}\n\nexport const getFoamVsCodeConfig = <T>(key: string, defaultValue?: T): T =>\n  workspace.getConfiguration('foam').get(key, defaultValue);\n\nexport const updateFoamVsCodeConfig = <T>(key: string, value: T) =>\n  workspace.getConfiguration().update('foam.' + key, value);\n\nexport const monitorFoamVsCodeConfig = <T>(\n  key: string\n): ConfigurationMonitor<T> => {\n  let value: T = getFoamVsCodeConfig(key);\n  const listener = workspace.onDidChangeConfiguration(e => {\n    if (e.affectsConfiguration('foam.' + key)) {\n      value = getFoamVsCodeConfig(key);\n    }\n  });\n  const ret = () => {\n    return value;\n  };\n  ret.dispose = () => listener.dispose();\n  return ret;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/services/editor.spec.ts",
    "content": "import { Selection, workspace } from 'vscode';\nimport { fromVsCodeUri } from '../utils/vsc-utils';\nimport {\n  closeEditors,\n  createFile,\n  showInEditor,\n} from '../test/test-utils-vscode';\nimport {\n  asAbsoluteWorkspaceUri,\n  getCurrentEditorDirectory,\n  replaceSelection,\n} from './editor';\nimport { URI } from '../core/model/uri';\n\ndescribe('Editor utils', () => {\n  beforeAll(closeEditors);\n  beforeAll(closeEditors);\n\n  describe('getCurrentEditorDirectory', () => {\n    it('should return the directory of the active text editor', async () => {\n      const file = await createFile('this is the file content.', [\n        'editor-utils',\n        'file.md',\n      ]);\n      await showInEditor(file.uri);\n\n      expect(getCurrentEditorDirectory()).toEqual(file.uri.getDirectory());\n    });\n\n    it('should throw if no editor is open', async () => {\n      await closeEditors();\n      expect(() => getCurrentEditorDirectory()).toThrow();\n    });\n  });\n\n  describe('replaceSelection', () => {\n    it('should replace the selection in the active editor', async () => {\n      const fileA = await createFile('This is the file A', [\n        'replace-selection',\n        'file.md',\n      ]);\n      const doc = await showInEditor(fileA.uri);\n      const selection = new Selection(0, 5, 0, 7); // 'is'\n\n      await replaceSelection(doc.doc, selection, 'was');\n\n      expect(doc.doc.getText()).toEqual('This was the file A');\n    });\n  });\n\n  describe('asAbsoluteWorkspaceUri', () => {\n    it('should work with the VS Code workspace folders if none are passed', () => {\n      const uri = URI.file('relative/path');\n      const workspaceFolder = workspace.workspaceFolders[0];\n      expect(asAbsoluteWorkspaceUri(uri)).toEqual(\n        fromVsCodeUri(workspaceFolder.uri).joinPath(uri.path)\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/services/editor.ts",
    "content": "import { isEmpty } from 'lodash';\nimport {\n  EndOfLine,\n  FileType,\n  RelativePattern,\n  Selection,\n  SnippetString,\n  TextDocument,\n  TextEditor,\n  Uri,\n  ViewColumn,\n  window,\n  workspace,\n  WorkspaceEdit,\n  MarkdownString,\n} from 'vscode';\nimport { getExcerpt, stripFrontMatter, stripImages } from '../core/utils/md';\nimport { isSome } from '../core/utils/core';\nimport { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';\nimport { asAbsoluteUri, URI } from '../core/model/uri';\nimport { getFoamVsCodeConfig } from './config';\nimport {\n  AlwaysIncludeMatcher,\n  FileListBasedMatcher,\n  GenericDataStore,\n  IDataStore,\n  IMatcher,\n} from '../core/services/datastore';\n\ninterface SelectionInfo {\n  document: TextDocument;\n  selection: Selection;\n  content: string;\n}\n\n/**\n * Returns a MarkdownString of the note content\n * @param note A Foam Note\n */\nexport function getNoteTooltip(content: string): string {\n  const strippedContent = stripFrontMatter(stripImages(content));\n  return formatMarkdownTooltip(strippedContent) as any;\n}\n\nexport function formatMarkdownTooltip(content: string): MarkdownString {\n  const LINES_LIMIT = 16;\n  const { excerpt, lines } = getExcerpt(content, LINES_LIMIT);\n  const totalLines = content.split('\\n').length;\n  const diffLines = totalLines - lines;\n  const ellipsis = diffLines > 0 ? `\\n\\n[...] *(+ ${diffLines} lines)*` : '';\n  const md = new MarkdownString(`${excerpt}${ellipsis}`);\n  md.isTrusted = true;\n  return md;\n}\n\n// Generate the document selector dynamically\nexport const getFoamDocSelectors = () =>\n  getFoamVsCodeConfig<string[]>('supportedLanguages', ['markdown']).flatMap(\n    lang => [\n      { language: lang, scheme: 'file' }, // Local files\n      { language: lang, scheme: 'vscode-vfs' }, // Remote files\n      { language: lang, scheme: 'untitled' }, // Untitled files\n    ]\n  );\n\n// Check if the editor's document is a supported language\nexport function isMdEditor(editor: TextEditor): boolean {\n  const supportedLanguages = getFoamVsCodeConfig<string[]>(\n    'supportedLanguages',\n    ['markdown']\n  );\n  return (\n    editor &&\n    editor.document &&\n    supportedLanguages.includes(editor.document.languageId)\n  );\n}\n\n/**\n * Check if the workspace contains remote or virtual file system folders.\n * @returns True if the workspace contains remote or virtual file system folders, false otherwise.\n */\nexport function isVirtualWorkspace(): boolean {\n  return workspace.workspaceFolders.some(folder => {\n    const scheme = folder.uri.scheme;\n    return scheme === 'vscode-remote' || scheme === 'vscode-vfs';\n  });\n}\n\nexport function getWorkspaceDefaultScheme(): string {\n  if (workspace.workspaceFolders === undefined) {\n    throw new Error('An open folder or workspace is required');\n  }\n  return workspace.workspaceFolders[0].uri.scheme;\n}\n\nexport function findSelectionContent(): SelectionInfo | undefined {\n  const editor = window.activeTextEditor;\n  if (editor === undefined) {\n    return undefined;\n  }\n\n  const document = editor.document;\n  const selection = editor.selection;\n\n  if (!document || selection.isEmpty) {\n    return undefined;\n  }\n\n  return {\n    document,\n    selection,\n    content: document.getText(selection),\n  };\n}\n\nexport async function focusNote(\n  notePath: URI,\n  moveCursorToEnd: boolean,\n  viewColumn: ViewColumn = ViewColumn.Active\n) {\n  const document = await workspace.openTextDocument(toVsCodeUri(notePath));\n  const editor = await window.showTextDocument(document, viewColumn);\n\n  // Move the cursor to end of the file\n  if (moveCursorToEnd) {\n    const { lineCount } = editor.document;\n    const { range } = editor.document.lineAt(lineCount - 1);\n    editor.selection = new Selection(range.end, range.end);\n  }\n\n  return { document, editor };\n}\n\nexport async function createDocAndFocus(\n  text: SnippetString,\n  filepath: URI,\n  viewColumn: ViewColumn = ViewColumn.Active\n) {\n  await workspace.fs.writeFile(\n    toVsCodeUri(filepath),\n    new TextEncoder().encode('')\n  );\n  const note = await focusNote(filepath, true, viewColumn);\n  await note.editor.insertSnippet(text);\n  await note.document.save();\n}\n\nexport async function replaceSelection(\n  document: TextDocument,\n  selection: Selection,\n  content: string\n) {\n  const originatingFileEdit = new WorkspaceEdit();\n  originatingFileEdit.replace(document.uri, selection, content);\n  await workspace.applyEdit(originatingFileEdit);\n}\n\n/**\n * Returns the EOL character for the currently open editor.\n */\nexport function getEditorEOL(): string {\n  return window.activeTextEditor.document.eol === EndOfLine.CRLF\n    ? '\\r\\n'\n    : '\\n';\n}\n\n/**\n * Returns the directory of the file currently open in the editor.\n * If no file is open in the editor it will throw.\n *\n * @returns URI\n * @throws Error if no file is open in editor\n */\nexport function getCurrentEditorDirectory(): URI {\n  const uri = window.activeTextEditor?.document?.uri;\n\n  if (isSome(uri)) {\n    return fromVsCodeUri(uri).getDirectory();\n  }\n\n  throw new Error('No editor open');\n}\n\nexport async function fileExists(uri: URI): Promise<boolean> {\n  try {\n    const stat = await workspace.fs.stat(toVsCodeUri(uri));\n    return stat.type === FileType.File;\n  } catch (e) {\n    return false;\n  }\n}\n\nexport async function readFile(uri: URI): Promise<string | undefined> {\n  if (await fileExists(uri)) {\n    return workspace.fs\n      .readFile(toVsCodeUri(uri))\n      .then(bytes => new TextDecoder('utf-8').decode(bytes));\n  }\n  return undefined;\n}\n\nexport function deleteFile(uri: URI) {\n  return workspace.fs.delete(toVsCodeUri(uri), { recursive: true });\n}\n\n/**\n * Turns a relative URI into an absolute URI for the given workspace.\n * @param uriOrPath the uri or path to evaluate\n * @returns an absolute uri\n */\nexport function asAbsoluteWorkspaceUri(uriOrPath: URI | string): URI {\n  if (workspace.workspaceFolders === undefined) {\n    throw new Error('An open folder or workspace is required');\n  }\n  const folders = workspace.workspaceFolders.map(folder =>\n    fromVsCodeUri(folder.uri)\n  );\n  return asAbsoluteUri(uriOrPath, folders);\n}\n\nexport async function createMatcherAndDataStore(\n  includes: string[],\n  excludes: string[]\n): Promise<{\n  matcher: IMatcher;\n  dataStore: IDataStore;\n  includePatterns: Map<string, string[]>;\n  excludePatterns: Map<string, string[]>;\n}> {\n  const includePatterns = new Map<string, string[]>();\n  const excludePatterns = new Map<string, string[]>();\n  workspace.workspaceFolders.forEach(f => {\n    includePatterns.set(f.name, []);\n    excludePatterns.set(f.name, []);\n  });\n\n  // Process include patterns\n  for (const include of includes) {\n    const tokens = include.split('/');\n    const matchesFolder = workspace.workspaceFolders.find(\n      f => f.name === tokens[0]\n    );\n    if (matchesFolder) {\n      includePatterns.get(tokens[0]).push(tokens.slice(1).join('/'));\n    } else {\n      for (const [, value] of includePatterns.entries()) {\n        value.push(include);\n      }\n    }\n  }\n\n  // Process exclude patterns\n  for (const exclude of excludes) {\n    const tokens = exclude.split('/');\n    const matchesFolder = workspace.workspaceFolders.find(\n      f => f.name === tokens[0]\n    );\n    if (matchesFolder) {\n      excludePatterns.get(tokens[0]).push(tokens.slice(1).join('/'));\n    } else {\n      for (const [, value] of excludePatterns.entries()) {\n        value.push(exclude);\n      }\n    }\n  }\n\n  const listFiles = async () => {\n    let allFiles: Uri[] = [];\n\n    for (const folder of workspace.workspaceFolders) {\n      const folderIncludes = includePatterns.get(folder.name);\n      const folderExcludes = excludePatterns.get(folder.name);\n      const excludePattern =\n        folderExcludes.length > 0\n          ? new RelativePattern(folder.uri, `{${folderExcludes.join(',')}}`)\n          : null;\n\n      // If includes are empty, include nothing\n      if (folderIncludes.length === 0) {\n        continue;\n      }\n\n      const filesFromAllPatterns: Uri[] = [];\n\n      // Apply each include pattern\n      for (const includePattern of folderIncludes) {\n        const uris = await workspace.findFiles(\n          new RelativePattern(folder.uri, includePattern),\n          excludePattern\n        );\n        filesFromAllPatterns.push(...uris);\n      }\n\n      // Deduplicate files (same file may match multiple patterns)\n      const uniqueFiles = Array.from(\n        new Map(filesFromAllPatterns.map(uri => [uri.fsPath, uri])).values()\n      );\n\n      allFiles = [...allFiles, ...uniqueFiles];\n    }\n\n    return allFiles.map(fromVsCodeUri);\n  };\n\n  const decoder = new TextDecoder('utf-8');\n  const readFile = async (uri: URI) => {\n    const content = await workspace.fs.readFile(toVsCodeUri(uri));\n    return decoder.decode(content);\n  };\n\n  const dataStore = new GenericDataStore(listFiles, readFile);\n  const matcher =\n    isEmpty(excludes) && includes.length === 1 && includes[0] === '**/*'\n      ? new AlwaysIncludeMatcher()\n      : await FileListBasedMatcher.createFromListFn(\n          listFiles,\n          includes,\n          excludes\n        );\n\n  return { matcher, dataStore, includePatterns, excludePatterns };\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/errors.ts",
    "content": "export class UserCancelledOperation extends Error {\n  constructor(message?: string) {\n    super('UserCancelledOperation');\n    if (message) {\n      this.message = message;\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/js-template-loader.ts",
    "content": "import * as vm from 'vm';\nimport { readFile } from './editor';\nimport { URI } from '../core/model/uri';\nimport { CreateNoteFunction, TemplateContext } from './note-creation-types';\nimport { createTemplateSandbox, BLOCKED_GLOBALS } from './js-template-sandbox';\nimport { Logger } from '../core/utils/log';\n\n/**\n * Error thrown when there are issues loading or executing JavaScript templates\n */\nexport class JSTemplateError extends Error {\n  constructor(message: string, public readonly templatePath: string) {\n    super(`JavaScript template error in ${templatePath}: ${message}`);\n    this.name = 'JSTemplateError';\n  }\n}\n\n/**\n * Loader for JavaScript template functions with secure VM execution\n */\nexport class JSTemplateLoader {\n  private static readonly EXECUTION_TIMEOUT = 10000; // 10 seconds\n  private static readonly VM_OPTIONS: vm.RunningScriptOptions = {\n    timeout: JSTemplateLoader.EXECUTION_TIMEOUT,\n    displayErrors: true,\n  };\n\n  /**\n   * Loads and returns a note creation function from a JavaScript template file\n   *\n   * @param template Path to the JavaScript template file\n   * @returns The createNote function from the template\n   */\n  async loadFunction(template: URI): Promise<CreateNoteFunction> {\n    try {\n      Logger.info(`Loading JavaScript template: ${template.path}`);\n\n      const templateCode = await readFile(template);\n\n      if (!templateCode) {\n        throw new JSTemplateError(\n          `Template file not found or empty`,\n          template.path\n        );\n      }\n\n      return this.createFunctionFromCode(templateCode, template);\n    } catch (error) {\n      if (error instanceof JSTemplateError) {\n        throw error;\n      }\n      throw new JSTemplateError(\n        `Failed to load template: ${error.message}`,\n        template.path\n      );\n    }\n  }\n\n  /**\n   * Creates a note creation function from JavaScript code\n   *\n   * @param code The JavaScript code containing the createNote function\n   * @param template Path for error reporting\n   * @returns The createNote function\n   */\n  private createFunctionFromCode(\n    code: string,\n    template: URI\n  ): CreateNoteFunction {\n    try {\n      // Validate the code structure\n      this.validateTemplateCode(code, template);\n\n      // Create the VM context with sandbox\n      const sandbox = this.createVMSandbox();\n      const context = vm.createContext(sandbox);\n\n      // Execute the template code in the sandbox\n      const script = new vm.Script(code, {\n        filename: template.toFsPath(),\n        lineOffset: 0,\n        columnOffset: 0,\n      });\n\n      script.runInContext(context, JSTemplateLoader.VM_OPTIONS);\n\n      // Extract the createNote function\n      const createNote = context.createNote;\n      if (typeof createNote !== 'function') {\n        throw new JSTemplateError(\n          'Template must declare a createNote function',\n          template.path\n        );\n      }\n\n      // Wrap the function to inject the sandbox context\n      return async (noteContext: TemplateContext) => {\n        try {\n          // Update the sandbox with the current context\n          const contextSandbox = createTemplateSandbox(noteContext);\n          Object.assign(context, contextSandbox);\n\n          // Execute the template function\n          const result = await createNote(noteContext);\n\n          // Validate the result\n          this.validateResult(result, template);\n\n          return result;\n        } catch (error) {\n          if (error instanceof JSTemplateError) {\n            throw error;\n          }\n          throw new JSTemplateError(\n            `Template execution failed: ${error.message}`,\n            template.path\n          );\n        }\n      };\n    } catch (error) {\n      if (error instanceof JSTemplateError) {\n        throw error;\n      }\n      throw new JSTemplateError(\n        `Failed to create function: ${error.message}`,\n        template.path\n      );\n    }\n  }\n\n  /**\n   * Creates a secure VM sandbox with limited globals\n   */\n  private createVMSandbox() {\n    const sandbox: Record<string, any> = {};\n\n    // Block dangerous globals\n    BLOCKED_GLOBALS.forEach(globalName => {\n      sandbox[globalName] = undefined;\n    });\n\n    return sandbox;\n  }\n\n  /**\n   * Validates that the template code has the expected structure\n   */\n  private validateTemplateCode(code: string, template: URI): void {\n    // Check for createNote function\n    if (\n      !code.includes('function createNote') &&\n      !code.includes('createNote =')\n    ) {\n      throw new JSTemplateError(\n        'Template must define a createNote function',\n        template.path\n      );\n    }\n\n    // Check for potentially dangerous patterns\n    const dangerousPatterns = [\n      /require\\s*\\(/,\n      /import\\s+/,\n      /eval\\s*\\(/,\n      /Function\\s*\\(/,\n      /process\\./,\n      /__dirname/,\n      /__filename/,\n    ];\n\n    for (const pattern of dangerousPatterns) {\n      if (pattern.test(code)) {\n        throw new JSTemplateError(\n          `Template contains potentially unsafe code: ${pattern.source}`,\n          template.path\n        );\n      }\n    }\n  }\n\n  /**\n   * Validates the result returned by a template function\n   */\n  private validateResult(result: any, template: URI): void {\n    if (!result || typeof result !== 'object') {\n      throw new JSTemplateError(\n        'Template must return an object with filepath and content properties',\n        template.path\n      );\n    }\n\n    if (typeof result.filepath !== 'string' || !result.filepath.trim()) {\n      throw new JSTemplateError(\n        'Template result must have a non-empty filepath string',\n        template.path\n      );\n    }\n\n    if (typeof result.content !== 'string') {\n      throw new JSTemplateError(\n        'Template result must have a content string',\n        template.path\n      );\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/js-template-sandbox.ts",
    "content": "import { TemplateContext } from './note-creation-types';\nimport { URI } from '../core/model/uri';\nimport { toSlug } from '../core/utils/slug';\nimport { Logger } from '../core/utils/log';\nimport dayjs from 'dayjs';\n\n/**\n * Creates a sandbox environment for JavaScript template execution\n * This provides utility functions and safe globals for template functions\n */\nexport function createTemplateSandbox(context: TemplateContext) {\n  return {\n    // Common JavaScript globals (safe subset)\n    Date,\n    Math,\n    Object,\n    Array,\n    String,\n    Number,\n    Boolean,\n    JSON,\n    RegExp,\n    Error,\n\n    // Console for debugging (logs to Foam output channel)\n    console: {\n      log: (...args: any[]) =>\n        Logger.info(`[Template] ${args[0]}`, ...args.slice(1)),\n      warn: (...args: any[]) =>\n        Logger.warn(`[Template] ${args[0]}`, ...args.slice(1)),\n      error: (...args: any[]) =>\n        Logger.error(`[Template] ${args[0]}`, ...args.slice(1)),\n    },\n\n    // Utility functions\n    dayjs,\n    slugify: toSlug,\n    URI,\n  };\n}\n\n/**\n * List of globals that should NOT be available in the template sandbox\n * for security reasons\n */\nexport const BLOCKED_GLOBALS = [\n  'require',\n  'module',\n  'exports',\n  '__dirname',\n  '__filename',\n  'global',\n  'process',\n  'Buffer',\n  'setImmediate',\n  'clearImmediate',\n  'setInterval',\n  'clearInterval',\n  'setTimeout',\n  'clearTimeout',\n  'eval',\n  'Function',\n];\n"
  },
  {
    "path": "packages/foam-vscode/src/services/logging.ts",
    "content": "import { workspace, window, commands, ExtensionContext } from 'vscode';\nimport { IDisposable } from '../core/common/lifecycle';\nimport { BaseLogger, ILogger, LogLevel } from '../core/utils/log';\n\nfunction getFoamLoggerLevel(): LogLevel {\n  return workspace.getConfiguration('foam.logging').get('level') ?? 'info';\n}\n\nexport interface VsCodeLogger extends ILogger, IDisposable {\n  show();\n}\n\nexport class VsCodeOutputLogger extends BaseLogger implements VsCodeLogger {\n  private channel = window.createOutputChannel('Foam');\n\n  constructor() {\n    super(getFoamLoggerLevel());\n    this.channel.appendLine('Foam Logging: ' + getFoamLoggerLevel());\n  }\n\n  log(lvl: LogLevel, msg?: any, ...extra: any[]): void {\n    if (msg) {\n      this.channel.appendLine(\n        `[${lvl} - ${new Date().toLocaleTimeString()}] ${msg}`\n      );\n    }\n    extra?.forEach(param => {\n      if (param?.stack) {\n        this.channel.appendLine(JSON.stringify(param.stack, null, 2));\n      } else {\n        this.channel.appendLine(JSON.stringify(param, null, 2));\n      }\n    });\n  }\n\n  show() {\n    this.channel.show();\n  }\n  dispose(): void {\n    this.channel.dispose();\n  }\n}\n\nexport const exposeLogger = (\n  context: ExtensionContext,\n  logger: VsCodeLogger\n): void => {\n  context.subscriptions.push(\n    commands.registerCommand('foam-vscode.set-log-level', async () => {\n      const items: LogLevel[] = ['debug', 'info', 'warn', 'error'];\n      const level = await window.showQuickPick(\n        items.map(item => ({\n          label: item,\n          description: item === logger.getLevel() && 'Current',\n        }))\n      );\n      logger.setLevel(level.label);\n    })\n  );\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/services/note-creation-engine.test.ts",
    "content": "import { tmpdir } from 'os';\nimport { mkdtempSync } from 'fs';\nimport { NoteCreationEngine } from './note-creation-engine';\nimport { TriggerFactory } from './note-creation-triggers';\nimport {\n  Template,\n  isCommandTrigger,\n  isPlaceholderTrigger,\n} from './note-creation-types';\nimport { readFileFromFs, strToUri } from '../test/test-utils';\nimport { bootstrap } from '../core/model/foam';\nimport { FileDataStore, Matcher } from '../test/test-datastore';\nimport { MarkdownResourceProvider } from '../core/services/markdown-provider';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { Logger } from '../core/utils/log';\nimport { Resolver } from './variable-resolver';\nimport { URI } from '../core/model/uri';\n\nLogger.setLevel('off');\n\nasync function setupFoamEngine() {\n  // Set up Foam workspace (minimal setup for testing)\n  const tmpDir = mkdtempSync(`${tmpdir()}/foam-test-`);\n  const dataStore = new FileDataStore(readFileFromFs, tmpDir);\n  const matcher = new Matcher([strToUri(tmpDir)], ['**/*.md']);\n  const parser = createMarkdownParser();\n  const provider = new MarkdownResourceProvider(dataStore, parser, ['.md']);\n  const roots = [strToUri(tmpDir)];\n  const foam = await bootstrap(roots, matcher, undefined, dataStore, parser, [\n    provider,\n  ]);\n  const engine = new NoteCreationEngine(foam);\n  return { foam, engine, tmpDir };\n}\n\ndescribe('NoteCreationEngine', () => {\n  describe('processTemplate', () => {\n    it('should process markdown templates correctly', async () => {\n      const { engine } = await setupFoamEngine();\n      // Create markdown template\n      const template: Template = {\n        type: 'markdown',\n        content: `---\nfilepath: test-note.md\n---\n# \\${FOAM_TITLE}\n\nTest content with title: \\${FOAM_TITLE}`,\n        metadata: new Map([['filepath', 'test-note.md']]),\n      };\n\n      // Create trigger\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Create resolver with variables\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'Test Note');\n\n      // Test processing\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      expect(result.filepath.path).toBe('test-note.md');\n      expect(result.content).toContain('# Test Note');\n      expect(result.content).toContain('Test content with title: Test Note');\n    });\n\n    it('should handle command triggers with date parameters', async () => {\n      const { engine } = await setupFoamEngine();\n      // Create markdown template with date variables\n      const template: Template = {\n        type: 'markdown',\n        metadata: new Map(),\n        content: `# Daily Note \\${FOAM_DATE_YEAR}-\\${FOAM_DATE_MONTH}-\\${FOAM_DATE_DATE}\n\nToday is \\${FOAM_DATE_DAY_NAME}`,\n      };\n\n      // Create context with date trigger\n      const testDate = new Date('2024-01-15');\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.open-daily-note',\n        {\n          date: testDate,\n        }\n      );\n\n      // Create resolver with date variables\n      const resolver = new Resolver(new Map(), testDate);\n      resolver.define('FOAM_TITLE', '2024-01-15');\n      resolver.define('FOAM_DATE_YEAR', '2024');\n      resolver.define('FOAM_DATE_MONTH', '01');\n      resolver.define('FOAM_DATE_DATE', '15');\n      resolver.define('FOAM_DATE_DAY_NAME', 'Monday');\n\n      // Test processing with date variables\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      expect(result.content).toContain('Daily Note 2024-01-15');\n      expect(result.content).toContain('Today is Monday');\n\n      // Verify trigger type handling\n      expect(trigger.type).toBe('command');\n      if (!isCommandTrigger(trigger)) {\n        throw new Error('Expected command trigger type');\n      }\n      expect(trigger.command).toBe('foam-vscode.open-daily-note');\n      expect(trigger.params).toHaveProperty('date');\n    });\n\n    it('should handle placeholder triggers correctly', async () => {\n      const { engine } = await setupFoamEngine();\n      // Create markdown template\n      const template: Template = {\n        type: 'markdown',\n        metadata: new Map(),\n        content: `# \\${FOAM_TITLE}\n\nCreated from placeholder link.\n\nContent goes here.`,\n      };\n\n      // Create placeholder trigger\n      const trigger = TriggerFactory.createPlaceholderTrigger(\n        strToUri('/test/source.md'),\n        'Source Note',\n        {\n          uri: strToUri('/test/source.md'),\n          range: {\n            start: { line: 0, character: 0 },\n            end: { line: 0, character: 10 },\n          },\n          data: { rawText: '[[Test Note]]' },\n        } as any\n      );\n\n      // Create resolver with variables\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'Test Note');\n\n      // Test processing\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      expect(result.content).toContain('# Test Note');\n      expect(result.content).toContain('Created from placeholder link');\n\n      // Verify trigger type handling\n      expect(trigger.type).toBe('placeholder');\n      if (!isPlaceholderTrigger(trigger)) {\n        throw new Error('Expected placeholder trigger type');\n      }\n      expect(trigger.sourceNote.title).toBe('Source Note');\n      expect(trigger.sourceNote.uri).toBe(\n        strToUri('/test/source.md').toString()\n      );\n    });\n\n    it('should generate default filepath when not specified in template', async () => {\n      const { engine } = await setupFoamEngine();\n      // Create markdown template without filepath metadata\n      const template: Template = {\n        type: 'markdown',\n        metadata: new Map(),\n        content: `# \\${FOAM_TITLE}\n\nContent without filepath metadata.`,\n      };\n\n      // Create resolver with variables\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'My New Note');\n      resolver.define('title', 'My New Note');\n\n      // Test processing\n      const result = await engine.processTemplate(\n        TriggerFactory.createCommandTrigger('foam-vscode.create-note'),\n        template,\n        resolver\n      );\n\n      expect(result.content).toContain('# My New Note');\n      expect(result.filepath.path).toBe('My New Note.md'); // Should generate from title\n    });\n\n    it('should handle JavaScript templates correctly', async () => {\n      const { engine } = await setupFoamEngine();\n      // Create JavaScript template\n      const template: Template = {\n        type: 'javascript',\n        createNote: async context => {\n          const title =\n            (await context.resolver.resolveFromName('FOAM_TITLE')) ||\n            'Untitled';\n          const content = `# ${title}\\n\\nGenerated by JavaScript template\\n\\nTrigger: ${context.trigger.type}`;\n          return {\n            filepath: URI.parse(\n              `${title.replace(/\\s+/g, '-').toLowerCase()}.md`,\n              'file'\n            ),\n            content,\n          };\n        },\n      };\n\n      // Create resolver with variables\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'JS Generated Note');\n      resolver.define('title', 'JS Generated Note');\n\n      // Test processing\n      const result = await engine.processTemplate(\n        TriggerFactory.createCommandTrigger('foam-vscode.create-note'),\n        template,\n        resolver\n      );\n\n      expect(result.content).toContain('# JS Generated Note');\n      expect(result.content).toContain('Generated by JavaScript template');\n      expect(result.content).toContain('Trigger: command');\n      expect(result.filepath.path).toBe('js-generated-note.md');\n    });\n\n    it('should preserve relative filepath from JS template so NoteFactory can apply onRelativePath strategy', async () => {\n      const { engine } = await setupFoamEngine();\n      const template: Template = {\n        type: 'javascript',\n        createNote: async () =>\n          ({ filepath: 'relative/note.md', content: '# Relative' } as any),\n      };\n      const resolver = new Resolver(new Map(), new Date());\n      const result = await engine.processTemplate(\n        TriggerFactory.createCommandTrigger('foam-vscode.create-note'),\n        template,\n        resolver\n      );\n      // Must remain non-absolute so NoteFactory's onRelativePath flow still runs\n      expect(result.filepath.isAbsolute()).toBe(false);\n      expect(result.filepath.path).toBe('relative/note.md');\n    });\n  });\n\n  describe('JavaScript template error handling', () => {\n    it('should handle synchronous errors thrown by JavaScript templates', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that throws synchronously\n      const template: Template = {\n        type: 'javascript',\n        createNote: () => {\n          throw new Error('Template execution failed');\n        },\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that error is properly caught and handled\n      await expect(\n        engine.processTemplate(trigger, template, resolver)\n      ).rejects.toThrow('Template execution failed');\n    });\n\n    it('should handle asynchronous errors thrown by JavaScript templates', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that throws asynchronously\n      const template: Template = {\n        type: 'javascript',\n        createNote: async () => {\n          await new Promise(resolve => setTimeout(resolve, 10));\n          throw new Error('Async template execution failed');\n        },\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that async error is properly caught and handled\n      await expect(\n        engine.processTemplate(trigger, template, resolver)\n      ).rejects.toThrow('Async template execution failed');\n    });\n\n    it('should handle JavaScript templates returning null/undefined', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that returns null\n      const nullTemplate: Template = {\n        type: 'javascript',\n        createNote: () => null as any,\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that null return is handled\n      await expect(\n        engine.processTemplate(trigger, nullTemplate, resolver)\n      ).rejects.toThrow();\n\n      // Create JavaScript template that returns undefined\n      const undefinedTemplate: Template = {\n        type: 'javascript',\n        createNote: () => undefined as any,\n      };\n\n      // Test that undefined return is handled\n      await expect(\n        engine.processTemplate(trigger, undefinedTemplate, resolver)\n      ).rejects.toThrow();\n    });\n\n    it('should handle JavaScript templates returning invalid data structures', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that returns object with missing filepath\n      const missingFilepathTemplate: Template = {\n        type: 'javascript',\n        createNote: () =>\n          ({\n            content: 'Valid content',\n            // Missing filepath\n          } as any),\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that missing filepath is handled\n      await expect(\n        engine.processTemplate(trigger, missingFilepathTemplate, resolver)\n      ).rejects.toThrow();\n\n      // Create JavaScript template that returns object with missing content\n      const missingContentTemplate: Template = {\n        type: 'javascript',\n        createNote: () =>\n          ({\n            filepath: 'valid-path.md',\n            // Missing content\n          } as any),\n      };\n\n      // Test that missing content is handled\n      await expect(\n        engine.processTemplate(trigger, missingContentTemplate, resolver)\n      ).rejects.toThrow();\n\n      // Create JavaScript template that returns wrong data types\n      const wrongTypesTemplate: Template = {\n        type: 'javascript',\n        createNote: () =>\n          ({\n            filepath: 123, // Should be string\n            content: true, // Should be string\n          } as any),\n      };\n\n      // Test that wrong data types are handled\n      await expect(\n        engine.processTemplate(trigger, wrongTypesTemplate, resolver)\n      ).rejects.toThrow();\n    });\n\n    it('should handle JavaScript templates with rejected promises', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that returns rejected promise\n      const rejectedPromiseTemplate: Template = {\n        type: 'javascript',\n        createNote: () => Promise.reject(new Error('Promise rejected')),\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that rejected promise is handled\n      await expect(\n        engine.processTemplate(trigger, rejectedPromiseTemplate, resolver)\n      ).rejects.toThrow('Promise rejected');\n    });\n\n    it('should handle JavaScript templates with mixed sync/async errors', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that sometimes throws sync, sometimes async\n      let callCount = 0;\n      const mixedErrorTemplate: Template = {\n        type: 'javascript',\n        createNote: () => {\n          callCount++;\n          if (callCount % 2 === 0) {\n            throw new Error('Sync error');\n          } else {\n            return Promise.reject(new Error('Async error'));\n          }\n        },\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test first call (async error)\n      await expect(\n        engine.processTemplate(trigger, mixedErrorTemplate, resolver)\n      ).rejects.toThrow('Async error');\n\n      // Test second call (sync error)\n      await expect(\n        engine.processTemplate(trigger, mixedErrorTemplate, resolver)\n      ).rejects.toThrow('Sync error');\n    });\n\n    it('should handle JavaScript templates that return promises resolving to invalid data', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Create JavaScript template that returns promise resolving to invalid data\n      const invalidPromiseTemplate: Template = {\n        type: 'javascript',\n        createNote: () =>\n          Promise.resolve({\n            filepath: null,\n            content: null,\n          } as any),\n      };\n\n      const resolver = new Resolver(new Map(), new Date());\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Test that invalid promise resolution is handled\n      await expect(\n        engine.processTemplate(trigger, invalidPromiseTemplate, resolver)\n      ).rejects.toThrow();\n    });\n  });\n\n  describe('trigger validation', () => {\n    it('should validate command triggers', () => {\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.open-daily-note',\n        { date: new Date() }\n      );\n\n      expect(trigger.type).toBe('command');\n      if (!isCommandTrigger(trigger)) {\n        throw new Error('Expected command trigger type');\n      }\n      expect(trigger.command).toBe('foam-vscode.open-daily-note');\n      expect(trigger.params).toHaveProperty('date');\n    });\n\n    it('should validate placeholder triggers', () => {\n      const sourceUri = strToUri('/test/source.md');\n      const mockLocation = {\n        uri: sourceUri,\n        range: {\n          start: { line: 0, character: 0 },\n          end: { line: 0, character: 10 },\n        },\n        data: { rawText: '[[Test Note]]' },\n      } as any;\n\n      const trigger = TriggerFactory.createPlaceholderTrigger(\n        sourceUri,\n        'Source Note',\n        mockLocation\n      );\n\n      expect(trigger.type).toBe('placeholder');\n      if (!isPlaceholderTrigger(trigger)) {\n        throw new Error('Expected placeholder trigger type');\n      }\n      expect(trigger.sourceNote).toMatchObject({\n        uri: sourceUri.toString(),\n        title: 'Source Note',\n        location: mockLocation,\n      });\n    });\n  });\n\n  describe('filepath sanitization', () => {\n    it('should sanitize invalid characters in filepath from template', async () => {\n      const { engine } = await setupFoamEngine();\n\n      const template: Template = {\n        type: 'markdown',\n        content: `---\nfoam_template:\n  filepath: \\${FOAM_TITLE}.md\n---\n# \\${FOAM_TITLE}`,\n        metadata: new Map(),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      // Title with many invalid characters\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'Test#%&{}<>?*$!\\'\"Title@+`|=');\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // All invalid characters should become dashes, and valid should stay unchanged\n      expect(result.filepath.path).toBe(\"Test#%&{}----$!'-Title@+`-=.md\");\n\n      // Content should remain unchanged\n      expect(result.content).toContain('# Test#%&{}<>?*$!\\'\"Title@+`|=');\n    });\n\n    it('should not affect FOAM_TITLE when not used in filepath', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Template with static filepath, FOAM_TITLE only in content\n      const template: Template = {\n        type: 'markdown',\n        content: `---\nfoam_template:\n  filepath: notes/static-file.md\n---\n# \\${FOAM_TITLE}\n\nContent with \\${FOAM_TITLE} should remain unchanged.`,\n        metadata: new Map(),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'Invalid \"Characters\" <Test>');\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Filepath should remain static (no sanitization needed)\n      expect(result.filepath.path).toBe('notes/static-file.md');\n\n      // Content should use original FOAM_TITLE with invalid characters\n      expect(result.content).toContain('# Invalid \"Characters\" <Test>');\n      expect(result.content).toContain(\n        'Content with Invalid \"Characters\" <Test> should remain'\n      );\n    });\n\n    it('should sanitize complex filepath patterns with multiple variables', async () => {\n      const { engine } = await setupFoamEngine();\n\n      const template: Template = {\n        type: 'markdown',\n        content: `---\nfoam_template:\n  filepath: \\${FOAM_DATE_YEAR}/\\${FOAM_DATE_MONTH}/\\${FOAM_TITLE}.md\n---\n# \\${FOAM_TITLE}\n\nDate and title combination.`,\n        metadata: new Map(),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const testDate = new Date('2024-03-15');\n      const resolver = new Resolver(new Map(), testDate);\n      resolver.define('FOAM_TITLE', 'Note:With|Invalid*Chars');\n      resolver.define('FOAM_DATE_YEAR', '2024');\n      resolver.define('FOAM_DATE_MONTH', '03');\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Entire resolved filepath should be sanitized\n      expect(result.filepath.path).toBe('2024/03/Note:With-Invalid-Chars.md');\n\n      // Content should use original FOAM_TITLE\n      expect(result.content).toContain('# Note:With|Invalid*Chars');\n    });\n\n    it('should handle filepath with no invalid characters', async () => {\n      const { engine } = await setupFoamEngine();\n\n      const template: Template = {\n        type: 'markdown',\n        content: `---\nfoam_template:\n  filepath: notes/\\${FOAM_TITLE}.md\n---\n# \\${FOAM_TITLE}`,\n        metadata: new Map(),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const resolver = new Resolver(new Map(), new Date());\n      resolver.define('FOAM_TITLE', 'ValidTitle123');\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // No sanitization needed - should remain unchanged\n      expect(result.filepath.path).toBe('notes/ValidTitle123.md');\n      expect(result.content).toContain('# ValidTitle123');\n    });\n\n    it('should preserve backslashes as directory separators (Windows-style paths)', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Simulate a resolved filepath with Windows-style backslash separators\n      const template: Template = {\n        type: 'markdown',\n        content: `# MyNote`,\n        metadata: new Map([\n          ['filepath', 'areas\\\\dailies\\\\2024\\\\MyNote.md'], // Already resolved, has backslashes\n        ]),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const resolver = new Resolver(new Map(), new Date());\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Backslashes should be normalized to forward slashes\n      expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md');\n      expect(result.content).toContain('# MyNote');\n    });\n\n    it('should normalize mixed forward and backslashes', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Simulate a resolved filepath with mixed separators\n      const template: Template = {\n        type: 'markdown',\n        content: `# MyNote`,\n        metadata: new Map([\n          ['filepath', 'areas/dailies\\\\2024/MyNote.md'], // Mixed separators\n        ]),\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const resolver = new Resolver(new Map(), new Date());\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Both separators should be normalized to forward slashes\n      expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md');\n      expect(result.content).toContain('# MyNote');\n    });\n\n    it('should sanitize invalid characters while normalizing backslash separators', async () => {\n      const { engine } = await setupFoamEngine();\n\n      // Simulate a resolved filepath with backslash separator and invalid chars\n      const template: Template = {\n        type: 'markdown',\n        content: `# Note:With*Invalid`,\n        metadata: new Map([['filepath', 'areas\\\\Note:With*Invalid.md']]), // Backslash + invalid chars\n      };\n\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.create-note'\n      );\n\n      const resolver = new Resolver(new Map(), new Date());\n\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Backslash normalized to forward slash, invalid chars sanitized\n      expect(result.filepath.path).toBe('areas/Note:With-Invalid.md');\n      expect(result.content).toContain('# Note:With*Invalid');\n    });\n  });\n\n  describe('filepath resolution via workspace.resolveUri', () => {\n    it('should not double an absolute filepath already under the workspace root', async () => {\n      const { engine, tmpDir } = await setupFoamEngine();\n      const root = strToUri(tmpDir);\n      const absolutePath = `${root.path}/journal/2025-10-20.md`;\n      const template: Template = {\n        type: 'markdown',\n        content: `# Daily Note`,\n        metadata: new Map([['filepath', absolutePath]]),\n      };\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.open-daily-note'\n      );\n      const resolver = new Resolver(new Map(), new Date());\n      const result = await engine.processTemplate(trigger, template, resolver);\n      expect(result.filepath.path).toBe(absolutePath);\n    });\n\n    it('should resolve a workspace-relative absolute filepath using root[0] as the base in a multi-root workspace', async () => {\n      const tmpDir1 = mkdtempSync(`${tmpdir()}/foam-test-`);\n      const tmpDir2 = mkdtempSync(`${tmpdir()}/foam-test-`);\n      const root1 = strToUri(tmpDir1);\n      const root2 = strToUri(tmpDir2);\n      const dataStore = new FileDataStore(readFileFromFs, tmpDir1);\n      const matcher = new Matcher([root1, root2], ['**/*.md']);\n      const parser = createMarkdownParser();\n      const provider = new MarkdownResourceProvider(dataStore, parser, ['.md']);\n      const foam = await bootstrap(\n        [root1, root2],\n        matcher,\n        undefined,\n        dataStore,\n        parser,\n        [provider]\n      );\n      const engine = new NoteCreationEngine(foam);\n\n      const template: Template = {\n        type: 'markdown',\n        content: `# Daily Note`,\n        metadata: new Map([['filepath', '/journal/2025-10-20.md']]),\n      };\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.open-daily-note'\n      );\n      const resolver = new Resolver(new Map(), new Date());\n      const result = await engine.processTemplate(trigger, template, resolver);\n\n      // Workspace-relative path '/journal/...' should resolve against root[0]\n      expect(result.filepath.path).toBe(`${root1.path}/journal/2025-10-20.md`);\n    });\n\n    it('should resolve a workspace-relative absolute filepath under the workspace root (#1537)', async () => {\n      const { engine, tmpDir } = await setupFoamEngine();\n      const root = strToUri(tmpDir);\n      const template: Template = {\n        type: 'markdown',\n        content: `# Daily Note`,\n        metadata: new Map([['filepath', '/journal/2025-10-20.md']]),\n      };\n      const trigger = TriggerFactory.createCommandTrigger(\n        'foam-vscode.open-daily-note'\n      );\n      const resolver = new Resolver(new Map(), new Date());\n      const result = await engine.processTemplate(trigger, template, resolver);\n      expect(result.filepath.path).toBe(`${root.path}/journal/2025-10-20.md`);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/services/note-creation-engine.ts",
    "content": "import { Resolver } from './variable-resolver';\nimport { Foam } from '../core/model/foam';\nimport { URI } from '../core/model/uri';\nimport { Logger } from '../core/utils/log';\nimport {\n  NoteCreationResult,\n  NoteCreationTrigger,\n  Template,\n  TemplateContext,\n  isCommandTrigger,\n  isPlaceholderTrigger,\n} from './note-creation-types';\nimport { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';\n/**\n * Characters that are invalid in file names\n * Based on UNALLOWED_CHARS from variable-resolver.ts but excluding filepaths\n * related chars and chars permissible in filepaths\n */\nconst FILEPATH_UNALLOWED_CHARS = '<>?*\"|';\n\n/**\n * Sanitizes a filepath by replacing invalid characters with dashes\n * @param filepath The filepath to sanitize\n * @returns The sanitized filepath\n */\nfunction sanitizeFilepath(filepath: string): string {\n  // Escape special regex characters and create character class\n  const escapedChars = FILEPATH_UNALLOWED_CHARS.replace(/[\\\\^\\-\\]]/g, '\\\\$&');\n  const regex = new RegExp(`[${escapedChars}]`, 'g');\n  return filepath.replace(regex, '-');\n}\n\n/**\n * Unified engine for creating notes from both Markdown and JavaScript templates\n */\nexport class NoteCreationEngine {\n  constructor(private foam: Foam) {}\n\n  /**\n   * Processes a template and generates note content and filepath\n   * This method only handles template processing, not file creation\n   *\n   * @param trigger The trigger that initiated the note creation\n   * @param template The template object containing content or function\n   * @param resolver Resolver instance with all variables pre-configured\n   * @returns Promise resolving to the generated content and filepath\n   */\n  async processTemplate(\n    trigger: NoteCreationTrigger,\n    template: Template,\n    resolver: Resolver\n  ): Promise<NoteCreationResult> {\n    Logger.info(`Processing ${template.type} template`);\n    this.logTriggerInfo(trigger);\n\n    let result: NoteCreationResult | null = null;\n    switch (template.type) {\n      case 'javascript':\n        result = await this.executeJSTemplate(trigger, template, resolver);\n        break;\n      case 'markdown':\n        result = await this.executeMarkdownTemplate(\n          trigger,\n          template,\n          resolver\n        );\n        break;\n      default:\n        throw new Error(`Unsupported template type: ${(template as any).type}`);\n    }\n\n    return result;\n  }\n\n  /**\n   * Executes a JavaScript template\n   */\n  private async executeJSTemplate(\n    trigger: NoteCreationTrigger,\n    template: Template & { type: 'javascript' },\n    resolver: Resolver\n  ): Promise<NoteCreationResult> {\n    const templateContext: TemplateContext = {\n      trigger,\n      resolver,\n      foam: this.foam,\n      foamDate: resolver.foamDate,\n    };\n\n    try {\n      const result = await template.createNote(templateContext);\n\n      // Validate the result structure and types\n      this.validateNoteCreationResult(result);\n\n      if (!(result.filepath instanceof URI)) {\n        const fp = result.filepath as string;\n        const isAbsolutePath = fp.startsWith('/') || /^[a-zA-Z]:/.test(fp);\n        result.filepath = isAbsolutePath\n          ? this.foam.workspace.resolveUri(fp)\n          : new URI({ scheme: 'file', path: fp.replace(/\\\\/g, '/') });\n      }\n      return result;\n    } catch (error) {\n      const errorMessage =\n        error instanceof Error ? error.message : String(error);\n      Logger.error(`JavaScript template execution failed: ${errorMessage}`);\n      throw new Error(`JavaScript template execution failed: ${errorMessage}`);\n    }\n  }\n\n  /**\n   * Executes a Markdown template using variable resolution\n   */\n  private async executeMarkdownTemplate(\n    trigger: NoteCreationTrigger,\n    template: Template & { type: 'markdown' },\n    resolver: Resolver\n  ): Promise<NoteCreationResult> {\n    // Use the provided resolver directly for variable resolution\n    const resolvedContent = await resolver.resolveText(template.content);\n\n    // Process frontmatter metadata\n    const [frontmatterMetadata, cleanContent] =\n      extractFoamTemplateFrontmatterMetadata(resolvedContent);\n\n    // Combine template metadata with frontmatter metadata (frontmatter takes precedence)\n    const metadata = new Map([\n      ...(template.metadata ?? new Map()),\n      ...frontmatterMetadata,\n    ]);\n\n    // Determine filepath - get variables from resolver for default generation\n    let filepath =\n      metadata.get('filepath') ??\n      (await this.generateDefaultFilepath(resolver));\n\n    // Sanitize the filepath to remove invalid characters\n    filepath = sanitizeFilepath(filepath);\n\n    // Only resolve absolute paths via workspace (fixes #1537 path doubling).\n    // Relative paths are left as-is for NoteFactory to resolve downstream,\n    // but backslashes are normalized to forward slashes (mirrors fromFsPath).\n    const isAbsolutePath =\n      filepath.startsWith('/') || /^[a-zA-Z]:/.test(filepath);\n    return {\n      filepath: isAbsolutePath\n        ? this.foam.workspace.resolveUri(filepath)\n        : new URI({ scheme: 'file', path: filepath.replace(/\\\\/g, '/') }),\n      content: cleanContent,\n    };\n  }\n\n  /**\n   * Generates a default filepath when none is specified in the template\n   */\n  private async generateDefaultFilepath(resolver: Resolver): Promise<string> {\n    const name =\n      (await resolver.resolveFromName('FOAM_TITLE_SAFE')) || 'untitled';\n    return `${name}.md`;\n  }\n\n  /**\n   * Validates the result returned by a JavaScript template\n   */\n  private validateNoteCreationResult(\n    result: any\n  ): asserts result is NoteCreationResult {\n    if (!result || typeof result !== 'object') {\n      throw new Error('JavaScript template must return an object');\n    }\n\n    if (\n      !Object.prototype.hasOwnProperty.call(result, 'filepath') ||\n      (typeof result.filepath !== 'string' && !(result.filepath instanceof URI))\n    ) {\n      throw new Error(\n        'JavaScript template result must have a \"filepath\" property of type string or URI'\n      );\n    }\n\n    if (\n      !Object.prototype.hasOwnProperty.call(result, 'content') ||\n      typeof result.content !== 'string'\n    ) {\n      throw new Error(\n        'JavaScript template result must have a \"content\" property of type string'\n      );\n    }\n\n    // Optional: Validate filepath doesn't contain dangerous characters\n    const invalidChars = /[<>:\"|?*\\x00-\\x1F]/; // eslint-disable-line no-control-regex\n    if (invalidChars.test(result.filepath.path)) {\n      throw new Error(\n        'JavaScript template result \"filepath\" contains invalid characters'\n      );\n    }\n  }\n\n  /**\n   * Logs trigger-specific information for debugging\n   */\n  private logTriggerInfo(trigger: NoteCreationTrigger): void {\n    if (isCommandTrigger(trigger)) {\n      Logger.info(`Note creation triggered by command: ${trigger.command}`);\n      if (trigger.params) {\n        Logger.info(`Command params:`, trigger.params);\n      }\n\n      // Handle specific commands\n      switch (trigger.command) {\n        case 'foam-vscode.open-daily-note': {\n          const date = trigger.params?.date;\n          Logger.info(`Daily note for date: ${date}`);\n          break;\n        }\n        case 'foam-vscode.create-note-from-template': {\n          const templateUri = trigger.params?.templateUri;\n          Logger.info(`Using template: ${templateUri}`);\n          break;\n        }\n        default:\n          Logger.info(`Generic command: ${trigger.command}`);\n      }\n    } else if (isPlaceholderTrigger(trigger)) {\n      const sourceNote = trigger.sourceNote;\n      Logger.info(`Creating note from placeholder in: ${sourceNote.title}`);\n      Logger.info(`Source URI: ${sourceNote.uri}`);\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/note-creation-triggers.ts",
    "content": "import { URI } from '../core/model/uri';\nimport { Location } from '../core/model/location';\nimport { ResourceLink } from '../core/model/note';\nimport { NoteCreationTrigger } from './note-creation-types';\n\n/**\n * Factory class for creating different types of note creation triggers\n */\nexport class TriggerFactory {\n  /**\n   * Creates a command trigger for note creation initiated by VS Code commands\n   *\n   * @param command The command name that triggered note creation\n   * @param params Optional parameters associated with the command\n   * @returns A command trigger object\n   */\n  static createCommandTrigger(\n    command: string,\n    params?: Record<string, any>\n  ): NoteCreationTrigger {\n    return { type: 'command', command, params };\n  }\n\n  /**\n   * Creates a placeholder trigger for note creation from wikilink placeholders\n   *\n   * @param sourceUri URI of the source note containing the placeholder\n   * @param sourceTitle Title of the source note\n   * @param location Location information for the placeholder in the source note\n   * @returns A placeholder trigger object\n   */\n  static createPlaceholderTrigger(\n    sourceUri: URI,\n    sourceTitle: string,\n    location: Location<ResourceLink>\n  ): NoteCreationTrigger {\n    return {\n      type: 'placeholder',\n      sourceNote: {\n        uri: sourceUri.toString(),\n        title: sourceTitle,\n        location,\n      },\n    };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/note-creation-types.ts",
    "content": "import { Location } from '../core/model/location';\nimport { ResourceLink } from '../core/model/note';\nimport { Foam } from '../core/model/foam';\nimport { Resolver } from './variable-resolver';\nimport { URI } from '../core/model/uri';\n\n/**\n * Union type for different trigger scenarios that can initiate note creation\n */\nexport type NoteCreationTrigger =\n  | {\n      type: 'command';\n      command: string;\n      params?: Record<string, any>; // Command arguments/parameters\n    }\n  | {\n      type: 'placeholder';\n      sourceNote: {\n        uri: string;\n        title: string;\n        location: Location<ResourceLink>;\n      };\n    };\n\n/**\n * Template types supported by the note creation system\n */\nexport type Template =\n  | { type: 'markdown'; content: string; metadata: Map<string, string> }\n  | {\n      type: 'javascript';\n      createNote: (context: TemplateContext) => Promise<NoteCreationResult>;\n    };\n\n/**\n * Context provided to JavaScript template functions\n */\nexport interface TemplateContext {\n  /** The trigger that initiated the note creation */\n  trigger: NoteCreationTrigger;\n  /** Resolver instance for variable resolution */\n  resolver: Resolver;\n  /** Foam instance for accessing workspace data */\n  foam: Foam;\n  /** Date used by the resolver for the FOAM_DATE_* variables */\n  foamDate: Date;\n}\n\n/**\n * Context for creating a note through the unified creation system\n */\n\n/**\n * Result returned by note creation functions\n */\nexport interface NoteCreationResult {\n  filepath: URI;\n  content: string;\n}\n\n/**\n * Function signature for JavaScript template functions\n */\nexport type CreateNoteFunction = (\n  context: TemplateContext\n) => Promise<NoteCreationResult> | NoteCreationResult;\n\n/**\n * Type guard to check if trigger is a command trigger\n */\nexport function isCommandTrigger(\n  trigger: NoteCreationTrigger\n): trigger is NoteCreationTrigger & { type: 'command' } {\n  return trigger.type === 'command';\n}\n\n/**\n * Type guard to check if trigger is a placeholder trigger\n */\nexport function isPlaceholderTrigger(\n  trigger: NoteCreationTrigger\n): trigger is NoteCreationTrigger & { type: 'placeholder' } {\n  return trigger.type === 'placeholder';\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/template-loader.spec.ts",
    "content": "/* @unit-ready */\nimport { workspace } from 'vscode';\nimport { TemplateLoader } from './template-loader';\nimport { createFile, deleteFile } from '../test/test-utils-vscode';\nimport { randomString } from '../test/test-utils';\n\ndescribe('TemplateLoader', () => {\n  describe('workspace trust', () => {\n    it('should throw error when loading JS template in untrusted workspace', async () => {\n      const templateLoader = new TemplateLoader();\n      const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get');\n      mockIsTrusted.mockReturnValue(false);\n\n      const { uri } = await createFile(\n        'function createNote() { return { filepath: \"test.md\", content: \"test\" }; }',\n        [`test-template-${randomString()}.js`]\n      );\n\n      try {\n        await expect(templateLoader.loadTemplate(uri)).rejects.toThrow(\n          'JavaScript templates can only be used in trusted workspaces for security reasons'\n        );\n      } finally {\n        await deleteFile(uri);\n        mockIsTrusted.mockRestore();\n      }\n    });\n\n    it('should load JS template successfully in trusted workspace', async () => {\n      const templateLoader = new TemplateLoader();\n      const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get');\n      mockIsTrusted.mockReturnValue(true);\n\n      const jsTemplateContent = `\n        function createNote(context) {\n          return {\n            filepath: 'test-note.md',\n            content: '# Test Note\\\\n\\\\nGenerated by JS template'\n          };\n        }\n      `;\n      const { uri } = await createFile(jsTemplateContent, [\n        `test-template-${randomString()}.js`,\n      ]);\n\n      try {\n        const template = await templateLoader.loadTemplate(uri);\n        expect(template.type).toBe('javascript');\n        if (template.type !== 'javascript') {\n          throw new Error('Expected JavaScript template type');\n        }\n        expect(template.createNote).toBeDefined();\n        expect(typeof template.createNote).toBe('function');\n      } finally {\n        await deleteFile(uri);\n      }\n    });\n\n    it('should load markdown template regardless of workspace trust', async () => {\n      const templateLoader = new TemplateLoader();\n      const mockIsTrusted = jest.spyOn(workspace, 'isTrusted', 'get');\n      mockIsTrusted.mockReturnValue(false);\n\n      const mdTemplateContent = `---\nname: Test Template\n---\n# Test Note\n\nThis is a markdown template.`;\n      const { uri } = await createFile(mdTemplateContent, [\n        `test-template-${randomString()}.md`,\n      ]);\n\n      try {\n        const template = await templateLoader.loadTemplate(uri);\n        expect(template.type).toBe('markdown');\n        if (template.type !== 'markdown') {\n          throw new Error('Expected markdown template type');\n        }\n        expect(template.content).toBe(mdTemplateContent);\n      } finally {\n        await deleteFile(uri);\n      }\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/services/template-loader.ts",
    "content": "import { workspace } from 'vscode';\nimport { URI } from '../core/model/uri';\nimport { readFile } from './editor';\nimport {\n  Template,\n  TemplateContext,\n  NoteCreationResult,\n} from './note-creation-types';\nimport { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';\nimport { JSTemplateLoader } from './js-template-loader';\n\n/**\n * Utility for loading templates from file paths and converting them to Template objects\n */\nexport class TemplateLoader {\n  private jsTemplateLoader: JSTemplateLoader;\n\n  constructor() {\n    this.jsTemplateLoader = new JSTemplateLoader();\n  }\n\n  /**\n   * Loads a template from a file path\n   * @param template Path to the template file (relative or absolute)\n   * @returns Promise resolving to a Template object\n   */\n  async loadTemplate(template: URI): Promise<Template> {\n    if (template.path.endsWith('.js')) {\n      if (!workspace.isTrusted) {\n        throw new Error(\n          'JavaScript templates can only be used in trusted workspaces for security reasons'\n        );\n      }\n      return await this.loadJavaScriptTemplate(template);\n    } else {\n      return await this.loadMarkdownTemplate(template);\n    }\n  }\n\n  /**\n   * Loads a JavaScript template\n   */\n  private async loadJavaScriptTemplate(template: URI): Promise<Template> {\n    const createNoteFunction = await this.jsTemplateLoader.loadFunction(\n      template\n    );\n\n    // Ensure the function returns a Promise\n    const createNote = async (\n      context: TemplateContext\n    ): Promise<NoteCreationResult> => {\n      const result = await createNoteFunction(context);\n      return result;\n    };\n\n    return {\n      type: 'javascript',\n      createNote,\n    };\n  }\n\n  /**\n   * Loads a Markdown template\n   */\n  private async loadMarkdownTemplate(template: URI): Promise<Template> {\n    const content = await readFile(template);\n\n    // Extract metadata from frontmatter if present\n    const [metadata] = extractFoamTemplateFrontmatterMetadata(content);\n\n    return {\n      type: 'markdown',\n      content,\n      metadata,\n    };\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/services/templates.spec.ts",
    "content": "/* @unit-ready */\nimport { Selection, window } from 'vscode';\nimport {\n  NoteFactory,\n  getTemplatesDir,\n  getTemplates,\n} from '../services/templates';\nimport {\n  closeEditors,\n  createFile,\n  deleteFile,\n  getUriInWorkspace,\n  showInEditor,\n  withModifiedFoamConfiguration,\n} from '../test/test-utils-vscode';\nimport { Resolver } from './variable-resolver';\nimport { fileExists } from './editor';\n\ndescribe('NoteFactory.createNote', () => {\n  beforeEach(async () => {\n    await closeEditors();\n  });\n  it('should create a new note', async () => {\n    const target = getUriInWorkspace();\n    await NoteFactory.createNote(\n      target,\n      'Hello World',\n      new Resolver(new Map(), new Date())\n    );\n    expect(await fileExists(target)).toBeTruthy();\n    expect(window.activeTextEditor.document.getText()).toEqual('Hello World');\n\n    await deleteFile(target);\n  });\n\n  it('should support not replacing the selection with a link to the newly created note', async () => {\n    const file = await createFile('This is my first file: World');\n    const { editor } = await showInEditor(file.uri);\n    editor.selection = new Selection(0, 23, 0, 28);\n    const target = getUriInWorkspace();\n    await NoteFactory.createNote(\n      target,\n      'Hello ${FOAM_SELECTED_TEXT} ${FOAM_SELECTED_TEXT}',\n      new Resolver(new Map(), new Date()),\n      undefined,\n      undefined,\n      false\n    );\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'Hello World World'\n    );\n    expect(window.visibleTextEditors[0].document.getText()).toEqual(\n      `This is my first file: World`\n    );\n    await deleteFile(file.uri);\n    await deleteFile(target);\n  });\n\n  it('should support replacing the selection with a link to the newly created note', async () => {\n    const file = await createFile('This is my first file: World');\n    const { editor } = await showInEditor(file.uri);\n    editor.selection = new Selection(0, 23, 0, 28);\n    const target = getUriInWorkspace();\n    await NoteFactory.createNote(\n      target,\n      'Hello ${FOAM_SELECTED_TEXT} ${FOAM_SELECTED_TEXT}',\n      new Resolver(new Map(), new Date()),\n      undefined,\n      undefined,\n      true\n    );\n    expect(window.activeTextEditor.document.getText()).toEqual(\n      'Hello World World'\n    );\n    expect(window.visibleTextEditors[0].document.getText()).toEqual(\n      `This is my first file: [[${target.getName()}]]`\n    );\n    await deleteFile(file.uri);\n    await deleteFile(target);\n  });\n});\n\ndescribe('getTemplatesDir', () => {\n  it('should return the default .foam/templates directory', () => {\n    const dir = getTemplatesDir();\n    expect(dir.path).toContain('.foam/templates');\n  });\n\n  it('should return the custom templates directory when foam.templates.folder is set', async () => {\n    await withModifiedFoamConfiguration(\n      'templates.folder',\n      'custom/templates',\n      async () => {\n        const dir = getTemplatesDir();\n        expect(dir.path).toContain('custom/templates');\n        expect(dir.path).not.toContain('.foam/templates');\n      }\n    );\n  });\n});\n\ndescribe('getTemplates', () => {\n  it('should find templates in a custom folder when foam.templates.folder is set', async () => {\n    await withModifiedFoamConfiguration(\n      'templates.folder',\n      'custom-tpl',\n      async () => {\n        const template = await createFile('# Custom template', [\n          'custom-tpl',\n          'my-template.md',\n        ]);\n        try {\n          const templates = await getTemplates();\n          const paths = templates.map(t => t.path);\n          expect(paths.some(p => p.includes('custom-tpl/my-template.md'))).toBe(\n            true\n          );\n        } finally {\n          await deleteFile(template.uri);\n        }\n      }\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/services/templates.ts",
    "content": "import { URI } from '../core/model/uri';\nimport {\n  SnippetString,\n  ViewColumn,\n  QuickPickItem,\n  commands,\n  window,\n  workspace,\n} from 'vscode';\nimport { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';\nimport { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';\nimport { UserCancelledOperation } from './errors';\nimport {\n  asAbsoluteWorkspaceUri,\n  createDocAndFocus,\n  deleteFile,\n  fileExists,\n  findSelectionContent,\n  focusNote,\n  getCurrentEditorDirectory,\n  readFile,\n  replaceSelection,\n} from './editor';\nimport { Resolver } from './variable-resolver';\nimport { getFoamVsCodeConfig } from './config';\nimport { isNone } from '../core/utils';\n\n/**\n * The templates directory\n */\nexport const getTemplatesDir = () => {\n  const folder = getFoamVsCodeConfig('templates.folder', '.foam/templates');\n  return fromVsCodeUri(workspace.workspaceFolders[0].uri).joinPath(\n    ...folder.split('/')\n  );\n};\n\n/**\n * Gets the candidate URIs for the default note template\n * @returns An array of candidate URIs for the default note template\n */\nexport const getDefaultNoteTemplateCandidateUris = () => [\n  getTemplatesDir().joinPath('new-note.js'),\n  getTemplatesDir().joinPath('new-note.md'),\n];\n\n/**\n * Gets the default template URI\n * @returns The URI of the default template or undefined if no default template is found\n */\nexport const getDefaultTemplateUri = async () => {\n  for (const uri of getDefaultNoteTemplateCandidateUris()) {\n    if (await fileExists(uri)) {\n      return uri;\n    }\n  }\n  return undefined;\n};\n\n/**\n * Gets the candidate URIs for the daily note template\n * @returns An array of candidate URIs for the daily note template\n */\nexport const getDailyNoteTemplateCandidateUris = () => [\n  getTemplatesDir().joinPath('daily-note.js'),\n  getTemplatesDir().joinPath('daily-note.md'),\n];\n\n/**\n * Gets the daily note template URI\n * @returns The URI of the daily note template or undefined if no template is found\n */\nexport const getDailyNoteTemplateUri = async () => {\n  for (const uri of getDailyNoteTemplateCandidateUris()) {\n    if (await fileExists(uri)) {\n      return uri;\n    }\n  }\n  return undefined;\n};\n\nconst DEFAULT_NEW_NOTE_TEMPLATE = `# \\${1:$TM_FILENAME_BASE}\n\nWelcome to Foam templates.\n\nWhat you see in the heading is a placeholder\n- it allows you to quickly move through positions of the new note by pressing TAB, e.g. to easily fill fields\n- a placeholder optionally has a default value, which can be some text or, as in this case, a [variable](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables)\n  - when landing on a placeholder, the default value is already selected so you can easily replace it\n- a placeholder can define a list of values, e.g.: \\${2|one,two,three|}\n- you can use variables even outside of placeholders, here is today's date: \\${CURRENT_YEAR}/\\${CURRENT_MONTH}/\\${CURRENT_DATE}\n\nFor a full list of features see [the VS Code snippets page](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_snippet-syntax).\n\n## To get started\n\n1. edit this file to create the shape new notes from this template will look like\n2. create a note from this template by running the \\`Foam: Create New Note From Template\\` command\n`;\n\nexport async function getTemplateMetadata(\n  templateUri: URI\n): Promise<Map<string, string>> {\n  const contents = (await readFile(templateUri)) ?? '';\n  const [templateMetadata] = extractFoamTemplateFrontmatterMetadata(contents);\n  return templateMetadata;\n}\n\nexport async function getTemplates(): Promise<URI[]> {\n  const folder = getFoamVsCodeConfig('templates.folder', '.foam/templates');\n  const templates = await workspace\n    .findFiles(`${folder}/**{.md,.js}`, null)\n    .then(v => v.map(uri => fromVsCodeUri(uri)));\n  return templates;\n}\n\nexport async function getTemplateInfo(\n  templateUri: URI,\n  templateFallbackText = '',\n  resolver: Resolver\n) {\n  const templateText = (await readFile(templateUri)) ?? templateFallbackText;\n\n  const templateWithResolvedVariables = await resolver.resolveText(\n    templateText\n  );\n\n  const [templateMetadata, templateWithFoamFrontmatterRemoved] =\n    extractFoamTemplateFrontmatterMetadata(templateWithResolvedVariables);\n\n  return {\n    metadata: templateMetadata,\n    text: templateWithFoamFrontmatterRemoved,\n  };\n}\n\nexport type OnFileExistStrategy =\n  | 'open'\n  | 'overwrite'\n  | 'cancel'\n  | 'ask'\n  | ((filePath: URI) => Promise<URI | undefined>);\n\nexport type OnRelativePathStrategy =\n  | 'resolve-from-root'\n  | 'resolve-from-current-dir'\n  | 'cancel'\n  | 'ask'\n  | ((filePath: URI) => Promise<URI | undefined>);\n\nexport async function askUserForTemplate() {\n  const templates = await getTemplates();\n  if (templates.length === 0) {\n    return offerToCreateTemplate();\n  }\n\n  const templatesMetadata = (\n    await Promise.all(\n      templates.map(async templateUri => {\n        const metadata = await getTemplateMetadata(templateUri);\n        metadata.set('templatePath', templateUri.getBasename());\n        return metadata;\n      })\n    )\n  ).sort(sortTemplatesMetadata);\n\n  const items: QuickPickItem[] = await Promise.all(\n    templatesMetadata.map(metadata => {\n      const label = metadata.get('name') || metadata.get('templatePath');\n      const description = metadata.get('name')\n        ? metadata.get('templatePath')\n        : null;\n      const detail = metadata.get('description');\n      const item = {\n        label: label,\n        description: description,\n        detail: detail,\n      };\n      Object.keys(item).forEach(key => {\n        if (!item[key]) {\n          delete item[key];\n        }\n      });\n      return item;\n    })\n  );\n\n  const selectedTemplate = await window.showQuickPick(items, {\n    placeHolder: 'Select a template to use.',\n  });\n\n  if (selectedTemplate === undefined) {\n    return undefined;\n  }\n  const templateFilename =\n    (selectedTemplate as QuickPickItem).description ||\n    (selectedTemplate as QuickPickItem).label;\n  const templateUri = getTemplatesDir().joinPath(templateFilename);\n  return templateUri;\n}\n\nasync function offerToCreateTemplate(): Promise<void> {\n  const response = await window.showQuickPick(['Yes', 'No'], {\n    placeHolder:\n      'No templates available. Would you like to create one instead?',\n  });\n  if (response === 'Yes') {\n    commands.executeCommand('foam-vscode.create-new-template');\n    return;\n  }\n}\n\nfunction sortTemplatesMetadata(\n  t1: Map<string, string>,\n  t2: Map<string, string>\n) {\n  // Sort by name's existence, then name, then path\n\n  if (t1.get('name') === undefined && t2.get('name') !== undefined) {\n    return 1;\n  }\n\n  if (t1.get('name') !== undefined && t2.get('name') === undefined) {\n    return -1;\n  }\n\n  const pathSortOrder = t1\n    .get('templatePath')\n    .localeCompare(t2.get('templatePath'));\n\n  if (t1.get('name') === undefined && t2.get('name') === undefined) {\n    return pathSortOrder;\n  }\n\n  const nameSortOrder = t1.get('name').localeCompare(t2.get('name'));\n\n  return nameSortOrder || pathSortOrder;\n}\n\nconst createFnForOnRelativePathStrategy =\n  (onRelativePath: OnRelativePathStrategy | undefined) =>\n  async (existingFile: URI) => {\n    // Get the default from the configuration\n    if (isNone(onRelativePath)) {\n      onRelativePath =\n        getFoamVsCodeConfig('files.newNotePath') === 'root'\n          ? 'resolve-from-root'\n          : 'resolve-from-current-dir';\n    }\n\n    if (typeof onRelativePath === 'function') {\n      return onRelativePath(existingFile);\n    }\n\n    switch (onRelativePath) {\n      case 'resolve-from-current-dir':\n        try {\n          return getCurrentEditorDirectory().joinPath(existingFile.path);\n        } catch (e) {\n          return asAbsoluteWorkspaceUri(existingFile);\n        }\n      case 'resolve-from-root':\n        return asAbsoluteWorkspaceUri(existingFile);\n      case 'cancel':\n        return undefined;\n      case 'ask':\n      default: {\n        const newProposedPath = await askUserForFilepathConfirmation(\n          existingFile\n        );\n        return newProposedPath && existingFile.forPath(newProposedPath);\n      }\n    }\n  };\n\nconst createFnForOnFileExistsStrategy =\n  (onFileExists: OnFileExistStrategy) => async (existingFile: URI) => {\n    if (typeof onFileExists === 'function') {\n      return onFileExists(existingFile);\n    }\n    switch (onFileExists) {\n      case 'open':\n        await commands.executeCommand('vscode.open', toVsCodeUri(existingFile));\n        return;\n      case 'overwrite':\n        await deleteFile(existingFile);\n        return existingFile;\n      case 'cancel':\n        return undefined;\n      case 'ask':\n      default: {\n        const newProposedPath = await askUserForFilepathConfirmation(\n          existingFile\n        );\n        return newProposedPath && existingFile.forPath(newProposedPath);\n      }\n    }\n  };\n\nexport const NoteFactory = {\n  createNote: async (\n    newFilePath: URI,\n    text: string,\n    resolver: Resolver,\n    onFileExistsStrategy?: OnFileExistStrategy,\n    onRelativePathStrategy?: OnRelativePathStrategy,\n    replaceSelectionWithLink = true\n  ): Promise<{ didCreateFile: boolean; uri: URI | undefined }> => {\n    try {\n      const onRelativePath = createFnForOnRelativePathStrategy(\n        onRelativePathStrategy\n      );\n      const onFileExists =\n        createFnForOnFileExistsStrategy(onFileExistsStrategy);\n\n      let resolvedNewFilePath = asAbsoluteWorkspaceUri(newFilePath);\n      /**\n       * Make sure the path is absolute and doesn't exist\n       */\n      while (\n        (await fileExists(resolvedNewFilePath)) ||\n        !newFilePath.isAbsolute()\n      ) {\n        while (!newFilePath.isAbsolute()) {\n          const proposedNewFilepath = await onRelativePath(newFilePath);\n          if (proposedNewFilepath === undefined) {\n            return { didCreateFile: false, uri: resolvedNewFilePath };\n          }\n          newFilePath = proposedNewFilepath;\n        }\n        resolvedNewFilePath = asAbsoluteWorkspaceUri(newFilePath);\n        while (\n          newFilePath.isAbsolute() &&\n          (await fileExists(resolvedNewFilePath))\n        ) {\n          const proposedNewFilepath = await onFileExists(resolvedNewFilePath);\n          if (proposedNewFilepath === undefined) {\n            return { didCreateFile: false, uri: resolvedNewFilePath };\n          }\n          newFilePath = proposedNewFilepath;\n          resolvedNewFilePath = asAbsoluteWorkspaceUri(newFilePath);\n        }\n      }\n\n      const expandedText = await resolver.resolveText(text);\n      const selectedContent = findSelectionContent();\n      await createDocAndFocus(\n        new SnippetString(expandedText),\n        resolvedNewFilePath,\n        selectedContent ? ViewColumn.Beside : ViewColumn.Active\n      );\n\n      if (replaceSelectionWithLink && selectedContent !== undefined) {\n        const newNoteTitle = resolvedNewFilePath.getName();\n\n        // This should really use the FoamWorkspace.getIdentifier() function,\n        // but for simplicity we just use newNoteTitle\n        await replaceSelection(\n          selectedContent.document,\n          selectedContent.selection,\n          `[[${newNoteTitle}]]`\n        );\n      }\n\n      return { didCreateFile: true, uri: resolvedNewFilePath };\n    } catch (err) {\n      if (err instanceof UserCancelledOperation) {\n        return;\n      }\n      throw err;\n    }\n  },\n};\n\nexport const createTemplate = async (): Promise<void> => {\n  const defaultFilename = 'new-template.md';\n  const defaultTemplate = getTemplatesDir().joinPath(defaultFilename);\n  const fsPath = defaultTemplate.toFsPath();\n  const filename = await window.showInputBox({\n    prompt: `Enter the filename for the new template`,\n    value: fsPath,\n    valueSelection: [fsPath.length - defaultFilename.length, fsPath.length - 3],\n    validateInput: async value =>\n      value.trim().length === 0\n        ? 'Please enter a value'\n        : (await fileExists(getTemplatesDir().forPath(value)))\n        ? 'File already exists'\n        : undefined,\n  });\n  if (filename === undefined) {\n    return;\n  }\n\n  const filenameURI = defaultTemplate.forPath(filename);\n  await workspace.fs.writeFile(\n    toVsCodeUri(filenameURI),\n    new TextEncoder().encode(DEFAULT_NEW_NOTE_TEMPLATE)\n  );\n  await focusNote(filenameURI, false);\n};\n\nasync function askUserForFilepathConfirmation(\n  defaultFilepath: URI\n): Promise<string | undefined> {\n  const fsPath = defaultFilepath.toFsPath();\n  const defaultFilename = defaultFilepath.getBasename();\n  const defaultExtension = defaultFilepath.getExtension();\n  return window.showInputBox({\n    prompt: `Enter the path for the new note`,\n    value: fsPath,\n    valueSelection: [\n      fsPath.length - defaultFilename.length,\n      fsPath.length - defaultExtension.length,\n    ],\n    validateInput: async value =>\n      value.trim().length === 0\n        ? 'Please enter a value'\n        : (await fileExists(getTemplatesDir().forPath(value)))\n        ? 'File already exists'\n        : !getTemplatesDir().forPath(value).isAbsolute()\n        ? 'Path needs to be absolute'\n        : undefined,\n  });\n}\n\nexport const getPathFromTitle = async (scheme: string, resolver: Resolver) => {\n  const defaultName = await resolver.resolveFromName('FOAM_TITLE_SAFE');\n  return new URI({ scheme, path: `${defaultName}.md` });\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/services/variable-resolver.spec.ts",
    "content": "/* @unit-ready */\nimport { Selection, window } from 'vscode';\nimport { Resolver } from './variable-resolver';\nimport { Variable } from '../core/common/snippetParser';\nimport {\n  createFile,\n  deleteFile,\n  showInEditor,\n  withModifiedFoamConfiguration,\n} from '../test/test-utils-vscode';\n\ndescribe('variable-resolver, text substitution', () => {\n  it('should do nothing if no Foam-specific variables are used', async () => {\n    const input = `\n      # \\${AnotherVariable} <-- Unrelated to Foam\n      # \\${AnotherVariable:default_value} <-- Unrelated to Foam\n      # \\${AnotherVariable:default_value/(.*)/\\${1:/upcase}/}} <-- Unrelated to Foam\n      # $AnotherVariable} <-- Unrelated to Foam\n      # $CURRENT_YEAR-\\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to Foam\n    `;\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_TITLE', 'My note title');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(input);\n  });\n\n  test('Ignores variable-looking text values', async () => {\n    // Related to https://github.com/foambubble/foam/issues/602\n    const input = `\n        # \\${CURRENT_DATE/.*/\\${FOAM_TITLE}/} <-- FOAM_TITLE is not a variable here, but a text in a transform\n        # \\${1|one,two,\\${FOAM_TITLE}|} <-- FOAM_TITLE is not a variable here, but a text in a choice\n      `;\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_TITLE', 'My note title');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(input);\n  });\n\n  it('should correctly substitute variables that are substrings of one another', async () => {\n    // FOAM_TITLE is a substring of FOAM_TITLE_NON_EXISTENT_VARIABLE\n    // If we're not careful with how we substitute the values\n    // we can end up putting the FOAM_TITLE in place FOAM_TITLE_NON_EXISTENT_VARIABLE should be.\n    const input = `\n        # \\${FOAM_TITLE}\n        # $FOAM_TITLE\n        # \\${FOAM_TITLE_NON_EXISTENT_VARIABLE}\n        # $FOAM_TITLE_NON_EXISTENT_VARIABLE\n      `;\n\n    const expected = `\n        # My note title\n        # My note title\n        # \\${FOAM_TITLE_NON_EXISTENT_VARIABLE}\n        # $FOAM_TITLE_NON_EXISTENT_VARIABLE\n      `;\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_TITLE', 'My note title');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n});\n\ndescribe('variable-resolver, variable resolution', () => {\n  it('should resolve FOAM_DATE_DAY_ISO correctly for all days', async () => {\n    // ISO weekday: Monday=1, Sunday=7\n    const isoResults = [\n      { js: 0, iso: '7' }, // Sunday\n      { js: 1, iso: '1' }, // Monday\n      { js: 2, iso: '2' }, // Tuesday\n      { js: 3, iso: '3' }, // Wednesday\n      { js: 4, iso: '4' }, // Thursday\n      { js: 5, iso: '5' }, // Friday\n      { js: 6, iso: '6' }, // Saturday\n    ];\n    for (const { js, iso } of isoResults) {\n      // 2025-09-14 is a Sunday, 2025-09-15 is a Monday, etc.\n      const date = new Date(2025, 8, 14 + js); // September is month 8 (0-based)\n      const resolver = new Resolver(new Map(), date);\n      const result = await resolver.resolve(new Variable('FOAM_DATE_DAY_ISO'));\n      expect(result).toBe(iso);\n    }\n  });\n  it('should do nothing for unknown Foam-specific variables', async () => {\n    const variables = [new Variable('FOAM_FOO')];\n\n    const expected = new Map<string, string>();\n\n    const givenValues = new Map<string, string>();\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  it('should resolve FOAM_TITLE if provided in constructor', async () => {\n    const foamTitle = 'My note title';\n\n    const expected = new Map<string, string>();\n    expected.set('FOAM_TITLE', foamTitle);\n    expected.set('FOAM_SLUG', 'my-note-title');\n\n    const variables = [new Variable('FOAM_TITLE'), new Variable('FOAM_SLUG')];\n\n    const resolver = new Resolver(\n      new Map<string, string>(),\n      new Date(),\n      foamTitle\n    );\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  it('should resolve FOAM_TITLE if provided as variable', async () => {\n    const foamTitle = 'My note title';\n    const variables = [new Variable('FOAM_TITLE'), new Variable('FOAM_SLUG')];\n\n    jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));\n\n    const expected = new Map<string, string>();\n    expected.set('FOAM_TITLE', foamTitle);\n    expected.set('FOAM_SLUG', 'my-note-title');\n\n    const givenValues = new Map<string, string>();\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  it('should resolve FOAM_TITLE without asking the user when it is provided', async () => {\n    const foamTitle = 'My note title';\n    const variables = [new Variable('FOAM_TITLE')];\n\n    const expected = new Map<string, string>();\n    expected.set('FOAM_TITLE', foamTitle);\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_TITLE', foamTitle);\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  it('should resolve FOAM_TITLE_SAFE', async () => {\n    const foamTitle = 'My/note#title';\n    const variables = [\n      new Variable('FOAM_TITLE'),\n      new Variable('FOAM_TITLE_SAFE'),\n    ];\n\n    const expected = new Map<string, string>();\n    expected.set('FOAM_TITLE', foamTitle);\n    expected.set('FOAM_TITLE_SAFE', 'My-note-title');\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_TITLE', foamTitle);\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  function getISOWeekYear(date: Date): number {\n    const temp = new Date(date.getTime());\n    // Set to Thursday of this week (ISO 8601 defines week based on Thursday)\n    temp.setDate(temp.getDate() + 3 - ((temp.getDay() + 6) % 7));\n    return temp.getFullYear();\n  }\n\n  it('should resolve FOAM_DATE_* properties with current day by default', async () => {\n    const variables = [\n      new Variable('FOAM_DATE_YEAR'),\n      new Variable('FOAM_DATE_YEAR_SHORT'),\n      new Variable('FOAM_DATE_MONTH'),\n      new Variable('FOAM_DATE_MONTH_NAME'),\n      new Variable('FOAM_DATE_MONTH_NAME_SHORT'),\n      new Variable('FOAM_DATE_DATE'),\n      new Variable('FOAM_DATE_DAY_NAME'),\n      new Variable('FOAM_DATE_DAY_NAME_SHORT'),\n      new Variable('FOAM_DATE_HOUR'),\n      new Variable('FOAM_DATE_MINUTE'),\n      new Variable('FOAM_DATE_SECOND'),\n      new Variable('FOAM_DATE_SECONDS_UNIX'),\n      new Variable('FOAM_DATE_DAY_ISO'),\n      new Variable('FOAM_DATE_WEEK_YEAR'),\n    ];\n\n    const expected = new Map<string, string>();\n    const now = new Date();\n    expected.set(\n      'FOAM_DATE_YEAR',\n      now.toLocaleString('default', { year: 'numeric' })\n    );\n    expected.set(\n      'FOAM_DATE_MONTH_NAME',\n      now.toLocaleString('default', { month: 'long' })\n    );\n    expected.set(\n      'FOAM_DATE_DATE',\n      now.toLocaleString('default', { day: '2-digit' })\n    );\n    expected.set('FOAM_DATE_DAY_ISO', String(((now.getDay() + 6) % 7) + 1));\n    expected.set('FOAM_DATE_WEEK_YEAR', String(getISOWeekYear(now)));\n\n    const givenValues = new Map<string, string>();\n    const resolver = new Resolver(givenValues, new Date());\n\n    expect(await resolver.resolveAll(variables)).toEqual(\n      expect.objectContaining(expected)\n    );\n  });\n\n  it('should resolve FOAM_DATE_* properties with given date', async () => {\n    const targetDate = new Date(2021, 9, 15, 1, 2, 3); // Friday, October 15, 2021\n    const variables = [\n      new Variable('FOAM_DATE_YEAR'),\n      new Variable('FOAM_DATE_YEAR_SHORT'),\n      new Variable('FOAM_DATE_MONTH'),\n      new Variable('FOAM_DATE_MONTH_NAME'),\n      new Variable('FOAM_DATE_MONTH_NAME_SHORT'),\n      new Variable('FOAM_DATE_DATE'),\n      new Variable('FOAM_DATE_DAY_NAME'),\n      new Variable('FOAM_DATE_DAY_NAME_SHORT'),\n      new Variable('FOAM_DATE_HOUR'),\n      new Variable('FOAM_DATE_MINUTE'),\n      new Variable('FOAM_DATE_SECOND'),\n      new Variable('FOAM_DATE_SECONDS_UNIX'),\n      new Variable('FOAM_DATE_WEEK'),\n      new Variable('FOAM_DATE_DAY_ISO'),\n      new Variable('FOAM_DATE_WEEK_YEAR'),\n    ];\n\n    const expected = new Map<string, string>();\n    expected.set('FOAM_DATE_YEAR', '2021');\n    expected.set('FOAM_DATE_YEAR_SHORT', '21');\n    expected.set('FOAM_DATE_MONTH', '10');\n    expected.set('FOAM_DATE_MONTH_NAME', 'October');\n    expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Oct');\n    expected.set('FOAM_DATE_DATE', '15');\n    expected.set('FOAM_DATE_DAY_NAME', 'Friday');\n    expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Fri');\n    expected.set('FOAM_DATE_HOUR', '01');\n    expected.set('FOAM_DATE_WEEK', '41');\n    expected.set('FOAM_DATE_MINUTE', '02');\n    expected.set('FOAM_DATE_SECOND', '03');\n    expected.set(\n      'FOAM_DATE_SECONDS_UNIX',\n      (targetDate.getTime() / 1000).toString()\n    );\n    expected.set('FOAM_DATE_DAY_ISO', '5'); // Friday is 5 in ISO 8601\n    expected.set('FOAM_DATE_WEEK_YEAR', '2021');\n\n    const givenValues = new Map<string, string>();\n    const resolver = new Resolver(givenValues, targetDate);\n\n    expect(await resolver.resolveAll(variables)).toEqual(expected);\n  });\n\n  describe('FOAM_DATE_WEEK', () => {\n    it('should start counting weeks from 1', async () => {\n      // week number starts from 1, not 0\n      // the first \"partial week\" of the year is really the last of the previous\n      const resolver = new Resolver(\n        new Map<string, string>(),\n        new Date(2021, 0, 1, 1, 2, 3)\n      );\n      expect(await resolver.resolve(new Variable('FOAM_DATE_WEEK'))).toEqual(\n        '53'\n      );\n    });\n\n    it('should pad week number to 2 digits', async () => {\n      // week number is 2-digit\n      const resolver = new Resolver(\n        new Map<string, string>(),\n        new Date(2021, 0, 7, 1, 2, 3)\n      );\n      expect(await resolver.resolve(new Variable('FOAM_DATE_WEEK'))).toEqual(\n        '01'\n      );\n    });\n  });\n\n  test.each([\n    [new Date(2025, 0, 1), '2025', '01'], // Jan 1 2025 (Wed) - week 1 of 2025\n    [new Date(2024, 11, 30), '2025', '01'], // Dec 30 2024 (Mon) - week 1 of 2025\n    [new Date(2024, 0, 1), '2024', '01'], // Jan 1 2024 (Mon) - week 1 of 2024\n    [new Date(2023, 0, 1), '2022', '52'], // Jan 1 2023 (Sun) - week 52 of 2022\n    [new Date(2022, 0, 1), '2021', '52'], // Jan 1 2022 (Sat) - week 52 of 2021\n  ])(\n    'should resolve FOAM_DATE_WEEK_YEAR correctly',\n    async (date, expectedWeekYear, expectedWeek) => {\n      const resolver = new Resolver(new Map(), date);\n      const weekYear = await resolver.resolve(\n        new Variable('FOAM_DATE_WEEK_YEAR')\n      );\n      const week = await resolver.resolve(new Variable('FOAM_DATE_WEEK'));\n\n      expect(weekYear).toBe(expectedWeekYear);\n      expect(week).toBe(expectedWeek);\n    }\n  );\n\n  it('should resolve FOAM_DATE_WEEK_YEAR with FOAM_DATE_WEEK in template', async () => {\n    // Example: 2024-W01 format where Dec 30, 2024 is in week 1 of 2025\n    const date = new Date(2024, 11, 30); // Dec 30, 2024 (Monday)\n    const resolver = new Resolver(new Map(), date);\n\n    const variables = [\n      new Variable('FOAM_DATE_WEEK_YEAR'),\n      new Variable('FOAM_DATE_WEEK'),\n      new Variable('FOAM_DATE_YEAR'),\n    ];\n\n    const result = await resolver.resolveAll(variables);\n\n    expect(result.get('FOAM_DATE_WEEK_YEAR')).toBe('2025');\n    expect(result.get('FOAM_DATE_WEEK')).toBe('01');\n    expect(result.get('FOAM_DATE_YEAR')).toBe('2024');\n  });\n\n  describe('foam.dateLocale', () => {\n    const targetDate = new Date(2021, 9, 15, 1, 2, 3); // Friday, October 15, 2021\n\n    it('should use en-US locale when foam.dateLocale is set to en-US', async () => {\n      await withModifiedFoamConfiguration('dateLocale', 'en-US', async () => {\n        const resolver = new Resolver(new Map(), targetDate);\n        expect(await resolver.resolve(new Variable('FOAM_DATE_DAY_NAME'))).toBe(\n          'Friday'\n        );\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_DAY_NAME_SHORT'))\n        ).toBe('Fri');\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_MONTH_NAME'))\n        ).toBe('October');\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_MONTH_NAME_SHORT'))\n        ).toBe('Oct');\n      });\n    });\n\n    it('should use ja-JP locale when foam.dateLocale is set to ja-JP', async () => {\n      await withModifiedFoamConfiguration('dateLocale', 'ja-JP', async () => {\n        const resolver = new Resolver(new Map(), targetDate);\n        expect(await resolver.resolve(new Variable('FOAM_DATE_DAY_NAME'))).toBe(\n          '金曜日'\n        );\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_DAY_NAME_SHORT'))\n        ).toBe('金');\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_MONTH_NAME'))\n        ).toBe('10月');\n        expect(\n          await resolver.resolve(new Variable('FOAM_DATE_MONTH_NAME_SHORT'))\n        ).toBe('10月');\n      });\n    });\n  });\n\n  describe('FOAM_CURRENT_DIR', () => {\n    it('should resolve to workspace root when no active editor', async () => {\n      const resolver = new Resolver(new Map<string, string>(), new Date());\n      const result = await resolver.resolve(new Variable('FOAM_CURRENT_DIR'));\n\n      // Should resolve to some directory path\n      expect(typeof result).toBe('string');\n      expect(result.length).toBeGreaterThan(0);\n    });\n\n    it('should resolve to current directory when editor is active', async () => {\n      // Create a test file in a subdirectory\n      const testFile = await createFile('Test content', [\n        'test-dir',\n        'test-file.md',\n      ]);\n\n      try {\n        // Open the file to make it the active editor\n        await showInEditor(testFile.uri);\n\n        const resolver = new Resolver(new Map<string, string>(), new Date());\n        const result = await resolver.resolve(new Variable('FOAM_CURRENT_DIR'));\n\n        // Should resolve to the test-dir directory\n        expect(typeof result).toBe('string');\n        expect(result).toContain('test-dir');\n      } finally {\n        // Clean up\n        await deleteFile(testFile.uri);\n      }\n    });\n\n    it('should be included in known foam variables', async () => {\n      const input = '${FOAM_CURRENT_DIR}';\n      const resolver = new Resolver(new Map(), new Date());\n      const result = await resolver.resolveText(input);\n\n      // Should resolve to a directory path, not remain as ${FOAM_CURRENT_DIR}\n      expect(result).not.toEqual(input);\n      expect(typeof result).toBe('string');\n      expect(result.length).toBeGreaterThan(0);\n    });\n\n    it('should return POSIX-style paths without backslashes for YAML compatibility (issue #1573)', async () => {\n      // Example: \"/c:/Users/name\" instead of \"C:\\Users\\name\"\n      const resolver = new Resolver(new Map<string, string>(), new Date());\n      const result = await resolver.resolve(new Variable('FOAM_CURRENT_DIR'));\n\n      expect(typeof result).toBe('string');\n      expect(result.length).toBeGreaterThan(0);\n\n      // Must not contain backslashes (which break YAML on Windows)\n      expect(result).not.toContain('\\\\');\n\n      // Must use forward slashes as path separators\n      expect(result).toContain('/');\n    });\n  });\n});\n\ndescribe('FOAM_DATE_FORMAT', () => {\n  const targetDate = new Date(2021, 9, 15, 1, 2, 3); // Friday, October 15, 2021 01:02:03\n\n  it('should resolve with ISO 8601 format by default', async () => {\n    const resolver = new Resolver(new Map(), targetDate);\n    const result = await resolver.resolveText('$FOAM_DATE_FORMAT');\n    // Should be local ISO 8601 with timezone offset, e.g. 2021-10-15T01:02:03+01:00\n    expect(result).toMatch(\n      /^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}[+-]\\d{2}:\\d{2}$/\n    );\n    expect(result).toContain('2021-10-15T01:02:03');\n  });\n\n  it('should resolve with a custom date-only format', async () => {\n    const resolver = new Resolver(new Map(), targetDate);\n    const result = await resolver.resolveText('${FOAM_DATE_FORMAT:YYYY-MM-DD}');\n    expect(result).toBe('2021-10-15');\n  });\n\n  it('should resolve with a custom time-only format', async () => {\n    const resolver = new Resolver(new Map(), targetDate);\n    const result = await resolver.resolveText('${FOAM_DATE_FORMAT:HH:mm:ss}');\n    expect(result).toBe('01:02:03');\n  });\n});\n\ndescribe('variable-resolver, resolveText', () => {\n  it('should do nothing for template without Foam-specific variables', async () => {\n    const input = `\n        # \\${AnotherVariable} <-- Unrelated to Foam\n        # \\${AnotherVariable:default_value} <-- Unrelated to Foam\n        # \\${AnotherVariable:default_value/(.*)/\\${1:/upcase}/}} <-- Unrelated to Foam\n        # $AnotherVariable} <-- Unrelated to Foam\n        # $CURRENT_YEAR-\\${CURRENT_MONTH}-$CURRENT_DAY <-- Unrelated to Foam\n      `;\n\n    const expected = input;\n\n    const resolver = new Resolver(new Map(), new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n\n  it.each([\n    ['2021-10-12T00:00:00'],\n    ['2021-10-12T23:59:59'],\n    ['2021-10-12T12:34:56'],\n  ])('should resolve date variables in local time', async (d: string) => {\n    // Related to #1502\n    const resolver = new Resolver(new Map(), new Date(d));\n    expect(await resolver.resolve(new Variable('FOAM_DATE_DATE'))).toEqual(\n      '12'\n    );\n  });\n\n  it('should do nothing for unknown Foam-specific variables', async () => {\n    const input = `\n        # $FOAM_FOO\n        # \\${FOAM_FOO}\n        # \\${FOAM_FOO:default_value}\n        # \\${FOAM_FOO:default_value/(.*)/\\${1:/upcase}/}}\n      `;\n\n    const expected = input;\n    const resolver = new Resolver(new Map(), new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n\n  it('should resolve FOAM_SELECTED_TEXT with the editor selection', async () => {\n    const file = await createFile('Content of note file');\n    const { editor } = await showInEditor(file.uri);\n    editor.selection = new Selection(0, 11, 1, 0);\n    const resolver = new Resolver(new Map(), new Date());\n    expect(await resolver.resolveFromName('FOAM_SELECTED_TEXT')).toEqual(\n      'note file'\n    );\n    await deleteFile(file);\n  });\n\n  it('should append FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in a newline', async () => {\n    const foamTitle = 'My note title';\n\n    jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));\n\n    const input = `# \\${FOAM_TITLE}\\n`;\n\n    const expected = `# My note title\\nSelected text\\n`;\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n\n  it('should append FOAM_SELECTED_TEXT with a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template ends in multiple newlines', async () => {\n    const foamTitle = 'My note title';\n\n    jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));\n\n    const input = `# \\${FOAM_TITLE}\\n\\n`;\n\n    const expected = `# My note title\\n\\nSelected text\\n`;\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n\n  it('should append FOAM_SELECTED_TEXT without a newline to the template if there is selected text but FOAM_SELECTED_TEXT is not referenced and the template does not end in a newline', async () => {\n    const foamTitle = 'My note title';\n\n    jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));\n\n    const input = `# \\${FOAM_TITLE}`;\n\n    const expected = '# My note title\\nSelected text';\n\n    const givenValues = new Map<string, string>();\n    givenValues.set('FOAM_SELECTED_TEXT', 'Selected text');\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n\n  it('should not append FOAM_SELECTED_TEXT to a template if there is no selected text and is not referenced', async () => {\n    const foamTitle = 'My note title';\n\n    jest\n      .spyOn(window, 'showInputBox')\n      .mockImplementationOnce(jest.fn(() => Promise.resolve(foamTitle)));\n\n    const input = `\n        # \\${FOAM_TITLE}\n        `;\n\n    const expected = `\n        # My note title\n        `;\n\n    const givenValues = new Map<string, string>();\n    const resolver = new Resolver(givenValues, new Date());\n    expect(await resolver.resolveText(input)).toEqual(expected);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/services/variable-resolver.ts",
    "content": "import { findSelectionContent, getCurrentEditorDirectory } from './editor';\nimport { window, workspace } from 'vscode';\nimport { UserCancelledOperation } from './errors';\nimport { toSlug } from '../core/utils/slug';\nimport { getFoamVsCodeConfig } from './config';\nimport {\n  SnippetParser,\n  Variable,\n  VariableResolver,\n} from '../core/common/snippetParser';\nimport dayjs from 'dayjs';\n\nconst knownFoamVariables = new Set([\n  'FOAM_TITLE',\n  'FOAM_TITLE_SAFE',\n  'FOAM_SLUG',\n  'FOAM_SELECTED_TEXT',\n  'FOAM_CURRENT_DIR',\n  'FOAM_DATE_FORMAT',\n  'FOAM_DATE_YEAR',\n  'FOAM_DATE_YEAR_SHORT',\n  'FOAM_DATE_MONTH',\n  'FOAM_DATE_MONTH_NAME',\n  'FOAM_DATE_MONTH_NAME_SHORT',\n  'FOAM_DATE_DATE',\n  'FOAM_DATE_DAY_ISO',\n  'FOAM_DATE_WEEK',\n  'FOAM_DATE_WEEK_YEAR',\n  'FOAM_DATE_DAY_NAME',\n  'FOAM_DATE_DAY_NAME_SHORT',\n  'FOAM_DATE_HOUR',\n  'FOAM_DATE_MINUTE',\n  'FOAM_DATE_SECOND',\n  'FOAM_DATE_SECONDS_UNIX',\n]);\n\nexport class Resolver implements VariableResolver {\n  private promises = new Map<string, Promise<string | undefined>>();\n  /**\n   * Create a resolver\n   *\n   * @param givenValues the map of variable name to value\n   * @param foamDate the date used to fill FOAM_DATE_* variables\n   */\n  constructor(\n    private givenValues: Map<string, string>,\n    public foamDate: Date,\n    foamTitle?: string\n  ) {\n    if (foamTitle) {\n      this.givenValues.set('FOAM_TITLE', foamTitle);\n    }\n  }\n\n  /**\n   * Adds a variable definition in the resolver\n   *\n   * @param name the name of the variable\n   * @param value the value of the variable\n   */\n  define(name: string, value: string) {\n    this.givenValues.set(name, value);\n  }\n\n  /**\n   * Gets all defined variables as a plain object\n   * Useful for passing to JavaScript templates that expect extraParams\n   *\n   * @returns Record containing all defined variables\n   */\n  getVariables(): Record<string, string> {\n    return Object.fromEntries(this.givenValues);\n  }\n\n  /**\n   * Process a string, replacing the variables with their values\n   *\n   * @param text the text to resolve\n   * @returns an array, where the first element is the resolution map,\n   *          and the second is the processed text\n   */\n  async resolveText(text: string): Promise<string> {\n    let snippet = new SnippetParser().parse(text, false, false);\n    let foamVariablesInTemplate = new Set(\n      snippet\n        .variables()\n        .map(v => v.name)\n        .filter(name => knownFoamVariables.has(name))\n    );\n\n    // Add FOAM_SELECTED_TEXT to the template text if required\n    // and re-parse the template text.\n    if (\n      this.givenValues.has('FOAM_SELECTED_TEXT') &&\n      !foamVariablesInTemplate.has('FOAM_SELECTED_TEXT')\n    ) {\n      const token = '$FOAM_SELECTED_TEXT';\n      if (text.endsWith('\\n')) {\n        text = `${text}${token}\\n`;\n      } else {\n        text = `${text}\\n${token}`;\n      }\n      snippet = new SnippetParser().parse(text, false, false);\n      foamVariablesInTemplate = new Set(\n        snippet\n          .variables()\n          .map(v => v.name)\n          .filter(name => knownFoamVariables.has(name))\n      );\n    }\n\n    await snippet.resolveVariables(this, foamVariablesInTemplate);\n    return snippet.snippetTextWithVariablesSubstituted(foamVariablesInTemplate);\n  }\n\n  /**\n   * Resolves a list of variables\n   *\n   * @param variables a list of variables to resolve\n   * @returns a Map of variable name to its value\n   */\n  async resolveAll(variables: Variable[]): Promise<Map<string, string>> {\n    await Promise.all(variables.map(variable => variable.resolve(this)));\n\n    const resolvedValues = new Map<string, string>();\n    variables.forEach(variable => {\n      if (variable.children.length > 0) {\n        resolvedValues.set(variable.name, variable.toString());\n      }\n    });\n    return resolvedValues;\n  }\n\n  /**\n   * Resolve a variable\n   *\n   * @param name the variable name\n   * @returns the resolved value, or the name of the variable if nothing is found\n   */\n  async resolveFromName(name: string): Promise<string> {\n    const variable = new Variable(name);\n    await variable.resolve(this);\n\n    return (variable.children[0] ?? name).toString();\n  }\n\n  async resolve(variable: Variable): Promise<string | undefined> {\n    const name = variable.name;\n    if (this.givenValues.has(name)) {\n      this.promises.set(name, Promise.resolve(this.givenValues.get(name)));\n    } else if (!this.promises.has(name)) {\n      let value: Promise<string | undefined> = Promise.resolve(undefined);\n      switch (name) {\n        case 'FOAM_TITLE':\n          value = resolveFoamTitle();\n          break;\n        case 'FOAM_TITLE_SAFE':\n          value = resolveFoamTitleSafe(this);\n          break;\n        case 'FOAM_SLUG':\n          value = toSlug(await this.resolve(new Variable('FOAM_TITLE')));\n          break;\n        case 'FOAM_SELECTED_TEXT':\n          value = Promise.resolve(resolveFoamSelectedText());\n          break;\n        case 'FOAM_CURRENT_DIR':\n          value = Promise.resolve(resolveFoamCurrentDir());\n          break;\n        case 'FOAM_DATE_FORMAT': {\n          const fmt =\n            variable.children.map(c => c.toString()).join('') ||\n            'YYYY-MM-DDTHH:mm:ssZ';\n          value = Promise.resolve(dayjs(this.foamDate).format(fmt));\n          break;\n        }\n        case 'FOAM_DATE_YEAR':\n          value = Promise.resolve(String(this.foamDate.getFullYear()));\n          break;\n        case 'FOAM_DATE_YEAR_SHORT':\n          value = Promise.resolve(\n            String(this.foamDate.getFullYear()).slice(-2)\n          );\n          break;\n        case 'FOAM_DATE_MONTH':\n          value = Promise.resolve(\n            String(this.foamDate.getMonth().valueOf() + 1).padStart(2, '0')\n          );\n          break;\n        case 'FOAM_DATE_MONTH_NAME': {\n          const locale = getFoamVsCodeConfig<string>('dateLocale', 'default');\n          value = Promise.resolve(\n            this.foamDate.toLocaleString(locale, { month: 'long' })\n          );\n          break;\n        }\n        case 'FOAM_DATE_MONTH_NAME_SHORT': {\n          const locale = getFoamVsCodeConfig<string>('dateLocale', 'default');\n          value = Promise.resolve(\n            this.foamDate.toLocaleString(locale, { month: 'short' })\n          );\n          break;\n        }\n        case 'FOAM_DATE_DATE':\n          value = Promise.resolve(\n            String(this.foamDate.getDate().valueOf()).padStart(2, '0')\n          );\n          break;\n        case 'FOAM_DATE_DAY_ISO':\n          // ISO 8601 weekday: Monday=1, Sunday=7\n          value = Promise.resolve(\n            String(((this.foamDate.getDay() + 6) % 7) + 1)\n          );\n          break;\n        case 'FOAM_DATE_WEEK': {\n          // https://en.wikipedia.org/wiki/ISO_8601#Week_dates\n          const date = new Date(this.foamDate);\n\n          // Find Thursday of this week starting on Monday\n          date.setDate(date.getDate() + 4 - (date.getDay() || 7));\n          const thursday = date.getTime();\n\n          // Find January 1st\n          date.setMonth(0); // January\n          date.setDate(1); // 1st\n          const janFirst = date.getTime();\n\n          // Round the amount of days to compensate for daylight saving time\n          const days = Math.round((thursday - janFirst) / 86400000); // 1 day = 86400000 ms\n          const weekDay = Math.floor(days / 7) + 1;\n          value = Promise.resolve(String(weekDay.valueOf()).padStart(2, '0'));\n          break;\n        }\n        case 'FOAM_DATE_WEEK_YEAR': {\n          // ISO 8601 week-numbering year\n          // The year that contains the Thursday of the current week\n          const date = new Date(this.foamDate);\n\n          // Find Thursday of this week starting on Monday\n          date.setDate(date.getDate() + 4 - (date.getDay() || 7));\n\n          // The year of this Thursday is the ISO week year\n          value = Promise.resolve(String(date.getFullYear()));\n          break;\n        }\n        case 'FOAM_DATE_DAY_NAME': {\n          const locale = getFoamVsCodeConfig<string>('dateLocale', 'default');\n          value = Promise.resolve(\n            this.foamDate.toLocaleString(locale, { weekday: 'long' })\n          );\n          break;\n        }\n        case 'FOAM_DATE_DAY_NAME_SHORT': {\n          const locale = getFoamVsCodeConfig<string>('dateLocale', 'default');\n          value = Promise.resolve(\n            this.foamDate.toLocaleString(locale, { weekday: 'short' })\n          );\n          break;\n        }\n        case 'FOAM_DATE_HOUR':\n          value = Promise.resolve(\n            String(this.foamDate.getHours().valueOf()).padStart(2, '0')\n          );\n          break;\n        case 'FOAM_DATE_MINUTE':\n          value = Promise.resolve(\n            String(this.foamDate.getMinutes().valueOf()).padStart(2, '0')\n          );\n          break;\n        case 'FOAM_DATE_SECOND':\n          value = Promise.resolve(\n            String(this.foamDate.getSeconds().valueOf()).padStart(2, '0')\n          );\n          break;\n        case 'FOAM_DATE_SECONDS_UNIX':\n          value = Promise.resolve(\n            (this.foamDate.getTime() / 1000).toString().padStart(2, '0')\n          );\n          break;\n        default:\n          value = Promise.resolve(undefined);\n          break;\n      }\n      this.promises.set(name, value);\n    }\n    const result = this.promises.get(name);\n    return result;\n  }\n}\n\nasync function resolveFoamTitle() {\n  const title = await window.showInputBox({\n    prompt: `Enter a title for the new note`,\n    value: 'Title of my New Note',\n    validateInput: value =>\n      value.trim().length === 0 ? 'Please enter a title' : undefined,\n  });\n  if (title === undefined) {\n    throw new UserCancelledOperation('User did not provide a note title');\n  }\n  return title;\n}\n\nfunction resolveFoamSelectedText() {\n  return findSelectionContent()?.content ?? '';\n}\n\nfunction resolveFoamCurrentDir() {\n  try {\n    // Try to get the directory of the currently active editor\n    const currentDir = getCurrentEditorDirectory();\n    return currentDir.path;\n  } catch (error) {\n    // Fall back to workspace root if no active editor\n    if (workspace.workspaceFolders && workspace.workspaceFolders.length > 0) {\n      return workspace.workspaceFolders[0].uri.path;\n    }\n    // If no workspace is open, raise\n    throw new Error('No workspace is open');\n  }\n}\n\n/**\n * Common chars that is better to avoid in file names.\n * Inspired by:\n *   https://www.mtu.edu/umc/services/websites/writing/characters-avoid/\n *   https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names\n * Even if some might be allowed in Win or Linux, to keep things more compatible and less error prone\n * we don't allow them\n * Also see https://github.com/foambubble/foam/issues/1042\n */\nconst UNALLOWED_CHARS = '/\\\\#%&{}<>?*$!\\'\":@+`|=';\n\n/**\n * Uses the title to generate a file path.\n * It sanitizes the title to remove special characters and spaces.\n *\n * @param resolver the resolver to use\n * @returns the string path of the new note\n */\nexport const resolveFoamTitleSafe = async (resolver: Resolver) => {\n  let safeTitle = await resolver.resolveFromName('FOAM_TITLE');\n  UNALLOWED_CHARS.split('').forEach(char => {\n    safeTitle = safeTitle.split(char).join('-');\n  });\n  return safeTitle;\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/services/watcher.ts",
    "content": "import { IDisposable } from '../core/common/lifecycle';\nimport { Emitter } from '../core/common/event';\nimport { IWatcher } from '../core/services/datastore';\nimport { URI } from '../core/model/uri';\nimport { FileSystemWatcher } from 'vscode';\nimport { fromVsCodeUri } from '../utils/vsc-utils';\n\nexport class VsCodeWatcher implements IWatcher, IDisposable {\n  public onDidCreateEmitter = new Emitter<URI>();\n  public onDidChangeEmitter = new Emitter<URI>();\n  public onDidDeleteEmitter = new Emitter<URI>();\n  onDidCreate = this.onDidCreateEmitter.event;\n  onDidChange = this.onDidChangeEmitter.event;\n  onDidDelete = this.onDidDeleteEmitter.event;\n\n  constructor(private readonly vsCodeWatcher: FileSystemWatcher) {\n    vsCodeWatcher.onDidCreate(uri =>\n      this.onDidCreateEmitter.fire(fromVsCodeUri(uri))\n    );\n    vsCodeWatcher.onDidChange(uri =>\n      this.onDidChangeEmitter.fire(fromVsCodeUri(uri))\n    );\n    vsCodeWatcher.onDidDelete(uri =>\n      this.onDidDeleteEmitter.fire(fromVsCodeUri(uri))\n    );\n  }\n  dispose(): void {\n    this.vsCodeWatcher.dispose();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/settings.spec.ts",
    "content": "/* @unit-ready */\nimport { getNotesExtensions, getIncludeFilesSetting } from './settings';\nimport { withModifiedFoamConfiguration } from './test/test-utils-vscode';\n\ndescribe('Default note settings', () => {\n  it('should default to .md', async () => {\n    const config = getNotesExtensions();\n    expect(config.defaultExtension).toEqual('.md');\n    expect(config.notesExtensions).toEqual(['.md']);\n  });\n\n  it('should always include the default note extension in the list of notes extensions', async () => {\n    withModifiedFoamConfiguration(\n      'files.defaultNoteExtension',\n      'mdxx',\n      async () => {\n        const { notesExtensions } = getNotesExtensions();\n        expect(notesExtensions).toEqual(['.mdxx']);\n\n        withModifiedFoamConfiguration(\n          'files.notesExtensions',\n          'md markdown',\n          async () => {\n            const { notesExtensions } = getNotesExtensions();\n            expect(notesExtensions).toEqual(\n              expect.arrayContaining(['.mdxx', '.md', '.markdown'])\n            );\n          }\n        );\n      }\n    );\n  });\n});\n\ndescribe('Include files settings', () => {\n  it('should default to **/* when not configured', () => {\n    const includes = getIncludeFilesSetting();\n    expect(includes).toEqual(['**/*']);\n  });\n\n  it('should return custom include patterns when configured', async () => {\n    await withModifiedFoamConfiguration(\n      'files.include',\n      ['notes/**'],\n      async () => {\n        const includes = getIncludeFilesSetting();\n        expect(includes).toEqual(['notes/**']);\n      }\n    );\n  });\n\n  it('should support multiple include patterns', async () => {\n    await withModifiedFoamConfiguration(\n      'files.include',\n      ['docs/**', 'notes/**', '**/*.md'],\n      async () => {\n        const includes = getIncludeFilesSetting();\n        expect(includes).toEqual(['docs/**', 'notes/**', '**/*.md']);\n      }\n    );\n  });\n\n  it('should expand alternate groups in include patterns', async () => {\n    await withModifiedFoamConfiguration(\n      'files.include',\n      ['**/*.{md,mdx,markdown}'],\n      async () => {\n        const includes = getIncludeFilesSetting();\n        expect(includes).toEqual(\n          expect.arrayContaining(['**/*.md', '**/*.mdx', '**/*.markdown'])\n        );\n        expect(includes.length).toBe(3);\n      }\n    );\n  });\n\n  it('should return empty array when configured with empty array', async () => {\n    await withModifiedFoamConfiguration('files.include', [], async () => {\n      const includes = getIncludeFilesSetting();\n      expect(includes).toEqual([]);\n    });\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/settings.ts",
    "content": "import { workspace, GlobPattern } from 'vscode';\nimport { uniq } from 'lodash';\nimport { getFoamVsCodeConfig } from './services/config';\nimport { expandAlternateGroups } from './utils/globExpand';\n\n/**\n * Gets the notes extensions and default extension from the config.\n *\n * @returns {notesExtensions: string[], defaultExtension: string}\n */\nexport function getNotesExtensions() {\n  const notesExtensionsFromSetting = getFoamVsCodeConfig(\n    'files.notesExtensions',\n    ''\n  )\n    .split(' ')\n    .filter(ext => ext.trim() !== '')\n    .map(ext => '.' + ext.trim());\n  const defaultExtension =\n    '.' +\n    (getFoamVsCodeConfig('files.defaultNoteExtension', 'md') ?? 'md').trim();\n\n  // we make sure that the default extension is always included in the list of extensions\n  const notesExtensions = uniq(\n    notesExtensionsFromSetting.concat(defaultExtension)\n  );\n\n  return { notesExtensions, defaultExtension };\n}\n\n/**\n * Gets the attachment extensions from the config.\n *\n * @returns string[]\n */\nexport function getAttachmentsExtensions() {\n  return getFoamVsCodeConfig('files.attachmentExtensions', '')\n    .split(' ')\n    .map(ext => '.' + ext.trim());\n}\n\n/** Retrieve the list of file exclude globs. */\nexport function getExcludedFilesSetting(): GlobPattern[] {\n  return [\n    '**/.foam/**',\n    ...workspace.getConfiguration().get('foam.files.exclude', []),\n    ...workspace.getConfiguration().get('foam.files.ignore', []), // deprecated, for backward compatibility\n    ...Object.keys(workspace.getConfiguration().get('files.exclude', {})),\n  ].flatMap(expandAlternateGroups);\n}\n\n/** Retrieve the directory link resolution mode. */\nexport function getDirectoryModeSetting(): 'resolve' | 'disabled' {\n  return getFoamVsCodeConfig('links.directory.mode', 'resolve');\n}\n\n/** Retrieve the list of file include globs. */\nexport function getIncludeFilesSetting(): GlobPattern[] {\n  return workspace\n    .getConfiguration()\n    .get('foam.files.include', ['**/*'])\n    .flatMap(expandAlternateGroups);\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/test/run-tests.ts",
    "content": "import path from 'path';\nimport { runTests } from 'vscode-test';\nimport { runUnit } from './suite-unit';\n\nfunction parseArgs(): {\n  unit: boolean;\n  e2e: boolean;\n  excludeSpecs: boolean;\n  jestArgs: string[];\n} {\n  const args = process.argv.slice(2);\n  const unit = args.includes('--unit');\n  const e2e = args.includes('--e2e');\n  const excludeSpecs = args.includes('--exclude-specs');\n\n  // Filter out our custom flags and pass the rest to Jest\n  const jestArgs = args.filter(\n    arg => !['--unit', '--e2e', '--exclude-specs'].includes(arg)\n  );\n\n  return unit || e2e\n    ? { unit, e2e, excludeSpecs, jestArgs }\n    : { unit: true, e2e: true, excludeSpecs, jestArgs };\n}\n\nfunction getVSCodePlatform(): string {\n  switch (process.platform) {\n    case 'darwin':\n      return process.arch === 'arm64' ? 'darwin-arm64' : 'darwin';\n    case 'win32':\n      return process.arch === 'arm64'\n        ? 'win32-arm64-archive'\n        : 'win32-x64-archive';\n    default: // linux\n      return process.arch === 'arm64' ? 'linux-arm64' : 'linux-x64';\n  }\n}\n\nasync function main() {\n  const { unit, e2e, excludeSpecs, jestArgs } = parseArgs();\n\n  let isSuccess = true;\n\n  if (unit) {\n    try {\n      console.log('Running unit tests');\n      await runUnit(jestArgs, excludeSpecs);\n    } catch (err) {\n      console.log('Error occurred while running Foam unit tests:', err);\n      isSuccess = false;\n    }\n  }\n\n  if (e2e) {\n    try {\n      console.log('Running e2e tests');\n      // The folder containing the Extension Manifest package.json\n      // Passed to `--extensionDevelopmentPath`\n      const extensionDevelopmentPath = path.join(__dirname, '..', '..');\n      // The path to the extension test script\n      // Passed to --extensionTestsPath\n      const extensionTestsPath = path.join(__dirname, 'suite');\n\n      const testWorkspace = path.join(\n        extensionDevelopmentPath,\n        '.test-workspace'\n      );\n\n      // Download VS Code, unzip it and run the integration test\n      await runTests({\n        extensionDevelopmentPath,\n        extensionTestsPath,\n        launchArgs: [\n          testWorkspace,\n          '--disable-gpu',\n          '--disable-extensions',\n          '--disable-workspace-trust',\n          '--disable-updates',\n        ],\n        platform: getVSCodePlatform(),\n        version: '1.109.0',\n      });\n    } catch (err) {\n      console.log('Error occurred while running Foam e2e tests:', err);\n      isSuccess = false;\n    }\n  }\n\n  if (!isSuccess) {\n    // throw new Error('Some Foam tests failed');\n    console.log('Some Foam tests failed');\n    process.exit(1);\n  }\n}\n\nmain();\n"
  },
  {
    "path": "packages/foam-vscode/src/test/suite-unit.ts",
    "content": "// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/testRunner.ts\n/**\n * We use the following convention in Foam:\n * - *.test.ts are unit tests\n *   they might still rely on vscode API and hence will be run in this environment, but\n *   are fundamentally about testing functions in isolation\n * - *.spec.ts are integration tests\n *   they will make direct use of the vscode API to be invoked as commands, create editors,\n *   and so on..\n */\n\n/* eslint-disable import/first */\n\n// Set before imports, see https://github.com/facebook/jest/issues/12162\nprocess.env.FORCE_COLOR = '1';\nprocess.env.NODE_ENV = 'test';\n\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport { runCLI } from '@jest/core';\nimport path from 'path';\nimport * as fs from 'fs';\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport * as glob from 'glob';\n\nconst rootDir = path.join(__dirname, '..', '..');\n\nfunction parseJestArgs(args: string[]): any {\n  const config: any = {};\n\n  for (let i = 0; i < args.length; i++) {\n    const arg = args[i];\n\n    if (arg === '--testNamePattern' && i + 1 < args.length) {\n      config.testNamePattern = args[i + 1];\n      i++; // Skip next arg as it's the value\n    } else if (arg === '--testPathPattern' && i + 1 < args.length) {\n      config.testPathPattern = args[i + 1].split('/').at(-1) || args[i + 1];\n      i++; // Skip next arg as it's the value\n    } else if (arg === '--json') {\n      config.json = true;\n    } else if (arg === '--useStderr') {\n      config.useStderr = true;\n    } else if (arg === '--outputFile' && i + 1 < args.length) {\n      config.outputFile = args[i + 1];\n      i++; // Skip next arg as it's the value\n    } else if (arg === '--no-coverage') {\n      config.collectCoverage = false;\n    } else if (arg === '--watchAll=false') {\n      config.watchAll = false;\n    } else if (arg === '--colors') {\n      config.colors = true;\n    } else if (arg === '--reporters' && i + 1 < args.length) {\n      if (!config.reporters) {\n        config.reporters = [];\n      }\n      config.reporters.push(args[i + 1]);\n      i++; // Skip next arg as it's the value\n    }\n  }\n\n  return config;\n}\n\nfunction getUnitReadySpecFiles(rootDir: string): string[] {\n  const specFiles = glob.sync('**/*.spec.ts', {\n    cwd: path.join(rootDir, 'src'),\n  });\n  const unitReadyFiles: string[] = [];\n\n  for (const file of specFiles) {\n    const fullPath = path.join(rootDir, 'src', file);\n    try {\n      const content = fs.readFileSync(fullPath, 'utf8');\n\n      // Check for @unit-ready annotation in file\n      if (\n        content.includes('/* @unit-ready */') ||\n        content.includes('// @unit-ready')\n      ) {\n        unitReadyFiles.push(file);\n      }\n    } catch (error) {\n      // Skip files that can't be read\n      continue;\n    }\n  }\n\n  return unitReadyFiles;\n}\n\nexport function runUnit(\n  extraArgs: string[] = [],\n  excludeSpecs = false\n): Promise<void> {\n  // eslint-disable-next-line no-async-promise-executor\n  return new Promise(async (resolve, reject) => {\n    try {\n      const { results } = await runCLI(\n        Object.assign(\n          {\n            rootDir,\n            roots: ['<rootDir>/src'],\n            runInBand: true,\n            testRegex: excludeSpecs\n              ? ['\\\\.(test)\\\\.ts$']\n              : (() => {\n                  const unitReadySpecs = getUnitReadySpecFiles(rootDir);\n\n                  // Create pattern that includes .test files + specific .spec files\n                  return [\n                    '\\\\.(test)\\\\.ts$', // All .test files\n                    ...unitReadySpecs.map(\n                      file =>\n                        file.replace(/\\//g, '\\\\/').replace(/\\./g, '\\\\.') + '$'\n                    ),\n                  ];\n                })(),\n            setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],\n            setupFilesAfterEnv: [\n              '<rootDir>/src/test/support/jest-setup-after-env.ts',\n            ],\n            testTimeout: 20000,\n            verbose: false,\n            silent: false,\n            colors: true,\n          },\n          // Parse additional Jest arguments into config object\n          parseJestArgs(extraArgs)\n        ) as any,\n        [rootDir]\n      );\n\n      const failures = results.testResults.reduce(\n        (acc, res) => (res.failureMessage ? acc + 1 : acc),\n        0\n      );\n\n      return failures === 0\n        ? resolve()\n        : reject(`${failures} tests have failed!`);\n    } catch (error) {\n      return reject(error);\n    }\n  });\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/test/suite.ts",
    "content": "// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/testRunner.ts\n/**\n * We use the following convention in Foam:\n * - *.test.ts are unit tests\n *   they might still rely on vscode API and hence will be run in this environment, but\n *   are fundamentally about testing functions in isolation\n * - *.spec.ts are integration tests\n *   they will make direct use of the vscode API to be invoked as commands, create editors,\n *   and so on..\n */\n\n/* eslint-disable import/first */\n\n// Set before imports, see https://github.com/facebook/jest/issues/12162\nprocess.env.FORCE_COLOR = '1';\nprocess.env.NODE_ENV = 'test';\n\nimport rf from 'rimraf';\n\n// eslint-disable-next-line import/no-extraneous-dependencies\nimport { runCLI } from '@jest/core';\nimport { cleanWorkspace } from './test-utils-vscode';\nimport path from 'path';\n\nconst rootDir = path.join(__dirname, '../..');\n\nexport function run(): Promise<void> {\n  const errWrite = process.stderr.write;\n\n  let remaining = '';\n  process.stderr.write = (buffer: string) => {\n    const lines = (remaining + buffer).split('\\n');\n    remaining = lines.pop() as string;\n    // Trim long lines because some uninformative code dumps will flood the\n    // console or, worse, be suppressed altogether because of their size.\n    lines.forEach(l => console.log(l.substr(0, 300)));\n    return true;\n  };\n\n  // process.on('unhandledRejection', err => {\n  //   throw err;\n  // });\n\n  // eslint-disable-next-line no-async-promise-executor\n  return new Promise(async (resolve, reject) => {\n    await cleanWorkspace();\n    const testWorkspace = path.join(__dirname, '..', '..', '.test-workspace');\n\n    // clean test workspace\n    rf.sync(path.join(testWorkspace, '*'));\n    rf.sync(path.join(testWorkspace, '.vscode'));\n    rf.sync(path.join(testWorkspace, '.foam'));\n    try {\n      const { results } = await runCLI(\n        {\n          rootDir,\n          roots: ['<rootDir>/src'],\n          runInBand: true,\n          testRegex: '\\\\.(test|spec)\\\\.ts$',\n          testEnvironment: '<rootDir>/src/test/support/vscode-environment.js',\n          setupFiles: ['<rootDir>/src/test/support/jest-setup-e2e.ts'],\n          testTimeout: 30000,\n          useStderr: true,\n          verbose: true,\n          colors: true,\n        } as any,\n        [rootDir]\n      );\n\n      const failures = results.testResults.filter(t => t.failureMessage);\n      // const failures = results.testResults.reduce((acc, res) => {\n      //   if (res.failureMessage) {\n      //     acc.push(res as any);\n      //   }\n      //   return acc;\n      // }, []);\n\n      if (failures.length > 0) {\n        console.log('Some Foam tests failed: ', failures.length);\n        reject(`Some Foam tests failed: ${failures.length}`);\n      } else {\n        resolve();\n      }\n    } catch (error) {\n      console.log('There was an error while running the Foam suite', error);\n      return reject(error);\n    } finally {\n      process.stderr.write = errWrite.bind(process.stderr);\n      await cleanWorkspace();\n    }\n  });\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/test/support/jest-setup-after-env.ts",
    "content": "// This file runs in the test environment where Jest globals are available\n\n// Clean up after each test file to prevent hanging threads\nafterAll(async () => {\n  const vscode = require('../vscode-mock');\n\n  // Force cleanup of any async operations\n  if (vscode.forceCleanup) {\n    await vscode.forceCleanup();\n  }\n  // Force garbage collection if available\n  if (global.gc) {\n    global.gc();\n  }\n\n  // Wait for any remaining async operations to complete\n  await new Promise(resolve => setImmediate(resolve));\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/test/support/jest-setup-e2e.ts",
    "content": "// Based on https://github.com/svsool/vscode-memo/blob/master/src/test/config/jestSetup.ts\njest.mock('vscode', () => (global as any).vscode, { virtual: true });\n"
  },
  {
    "path": "packages/foam-vscode/src/test/support/jest-setup.ts",
    "content": "jest.mock('vscode', () => require('../vscode-mock'), { virtual: true });\n"
  },
  {
    "path": "packages/foam-vscode/src/test/support/vscode-environment.js",
    "content": "const { TestEnvironment } = require('jest-environment-node');\nconst vscode = require('vscode');\n\nclass VscodeEnvironment extends TestEnvironment {\n  async setup() {\n    await super.setup();\n    this.global.vscode = vscode;\n\n    // Expose RegExp otherwise document.getWordRangeAtPosition won't work as supposed.\n    // Implementation of getWordRangeAtPosition uses \"instanceof RegExp\" which returns false\n    // due to Jest running tests in the different vm context.\n    // See https://github.com/nodejs/node-v0.x-archive/issues/1277.\n    // And also https://github.com/microsoft/vscode-test/issues/37#issuecomment-700167820\n    this.global.RegExp = RegExp;\n\n    vscode.workspace\n      .getConfiguration()\n      .update('foam.edit.linkReferenceDefinitions', 'off');\n  }\n\n  async teardown() {\n    this.global.vscode = {};\n    await super.teardown();\n  }\n}\n\nmodule.exports = VscodeEnvironment;\n"
  },
  {
    "path": "packages/foam-vscode/src/test/test-datastore.test.ts",
    "content": "import { URI } from '../core/model/uri';\nimport { Logger } from '../core/utils/log';\nimport { Matcher, toMatcherPathFormat } from './test-datastore';\nimport { TEST_DATA_DIR } from './test-utils';\n\nLogger.setLevel('error');\n\nconst testFolder = TEST_DATA_DIR.joinPath('test-datastore');\n\ndescribe('Matcher', () => {\n  it('generates globs with the base dir provided', () => {\n    const matcher = new Matcher([testFolder], ['*'], []);\n    expect(matcher.folders).toEqual([toMatcherPathFormat(testFolder)]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('*')),\n    ]);\n  });\n\n  it('defaults to including everything and excluding nothing', () => {\n    const matcher = new Matcher([testFolder]);\n    expect(matcher.exclude).toEqual([]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('**', '*')),\n    ]);\n  });\n\n  it('supports multiple includes', () => {\n    const matcher = new Matcher([testFolder], ['g1', 'g2'], []);\n    expect(matcher.exclude).toEqual([]);\n    expect(matcher.include).toEqual([\n      toMatcherPathFormat(testFolder.joinPath('g1')),\n      toMatcherPathFormat(testFolder.joinPath('g2')),\n    ]);\n  });\n\n  it('has a match method to filter strings', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], []);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.match(files)).toEqual([\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n    ]);\n  });\n\n  it('has a isMatch method to see whether a file is matched or not', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], []);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.isMatch(files[0])).toEqual(true);\n    expect(matcher.isMatch(files[1])).toEqual(true);\n    expect(matcher.isMatch(files[2])).toEqual(false);\n    expect(matcher.isMatch(files[3])).toEqual(false);\n  });\n\n  it('happy path', () => {\n    const matcher = new Matcher([URI.file('/root/')], ['**/*'], ['**/*.pdf']);\n    expect(matcher.isMatch(URI.file('/root/file.md'))).toBeTruthy();\n    expect(matcher.isMatch(URI.file('/root/file.pdf'))).toBeFalsy();\n    expect(matcher.isMatch(URI.file('/root/dir/file.md'))).toBeTruthy();\n    expect(matcher.isMatch(URI.file('/root/dir/file.pdf'))).toBeFalsy();\n  });\n\n  it('ignores files in the exclude list', () => {\n    const matcher = new Matcher([testFolder], ['*.md'], ['file1.*']);\n    const files = [\n      testFolder.joinPath('file1.md'),\n      testFolder.joinPath('file2.md'),\n      testFolder.joinPath('file3.mdx'),\n      testFolder.joinPath('sub', 'file4.md'),\n    ];\n    expect(matcher.isMatch(files[0])).toEqual(false);\n    expect(matcher.isMatch(files[1])).toEqual(true);\n    expect(matcher.isMatch(files[2])).toEqual(false);\n    expect(matcher.isMatch(files[3])).toEqual(false);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/test/test-datastore.ts",
    "content": "import micromatch from 'micromatch';\nimport { Logger } from '../core/utils/log';\nimport { IDataStore, IMatcher } from '../core/services/datastore';\nimport { URI } from '../core/model/uri';\nimport { isWindows } from '../core/common/platform';\nimport { asAbsolutePaths } from '../core/utils/path';\nimport fs from 'fs';\nimport path from 'path';\n\nfunction getFiles(directory: string) {\n  const files = [];\n  getFilesFromDir(files, directory);\n  return files;\n}\nfunction getFilesFromDir(files: string[], directory: string) {\n  fs.readdirSync(directory).forEach(file => {\n    const absolute = path.join(directory, file);\n    if (fs.statSync(absolute).isDirectory()) {\n      getFilesFromDir(files, absolute);\n    } else {\n      files.push(absolute);\n    }\n  });\n}\n/**\n * File system based data store\n */\nexport class FileDataStore implements IDataStore {\n  constructor(\n    private readFile: (uri: URI) => Promise<string>,\n    private readonly basedir: string\n  ) {}\n\n  async list(): Promise<URI[]> {\n    const res = getFiles(this.basedir);\n    return res.map(URI.file);\n  }\n\n  async read(uri: URI) {\n    try {\n      return await this.readFile(uri);\n    } catch (e) {\n      Logger.error(\n        `FileDataStore: error while reading uri: ${uri.path} - ${e}`\n      );\n      return null;\n    }\n  }\n}\n\n/**\n * The matcher requires the path to be in unix format, so if we are in windows\n * we convert the fs path on the way in and out\n */\nexport const toMatcherPathFormat = isWindows\n  ? (uri: URI) => uri.toFsPath().replace(/\\\\/g, '/')\n  : (uri: URI) => uri.toFsPath();\n\nexport const toFsPath = isWindows\n  ? (path: string): string => path.replace(/\\//g, '\\\\')\n  : (path: string): string => path;\n\nexport class Matcher implements IMatcher {\n  public readonly folders: string[];\n  public readonly include: string[] = [];\n  public readonly exclude: string[] = [];\n\n  constructor(\n    baseFolders: URI[],\n    includeGlobs: string[] = ['**/*'],\n    excludeGlobs: string[] = []\n  ) {\n    this.folders = baseFolders.map(toMatcherPathFormat);\n    Logger.info('Workspace folders: ', this.folders);\n\n    this.include = includeGlobs.flatMap(glob =>\n      asAbsolutePaths(glob, this.folders)\n    );\n    this.exclude = excludeGlobs.flatMap(glob =>\n      asAbsolutePaths(glob, this.folders)\n    );\n\n    Logger.info('Glob patterns', {\n      includeGlobs: this.include,\n      ignoreGlobs: this.exclude,\n    });\n  }\n\n  match(files: URI[]) {\n    const matches = micromatch(\n      files.map(f => f.toFsPath()),\n      this.include,\n      {\n        ignore: this.exclude,\n        nocase: true,\n        format: toFsPath,\n      }\n    );\n    return matches.map(URI.file);\n  }\n\n  isMatch(uri: URI) {\n    return this.match([uri]).length > 0;\n  }\n\n  refresh(): Promise<void> {\n    return Promise.resolve();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/test/test-utils-vscode.ts",
    "content": "/*\n * This file depends on VS Code as it's used for integration/e2e tests\n */\nimport * as vscode from 'vscode';\nimport path from 'path';\nimport { TextDecoder, TextEncoder } from 'util';\nimport { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';\nimport { Logger } from '../core/utils/log';\nimport { URI } from '../core/model/uri';\nimport { Resource } from '../core/model/note';\nimport { randomString, wait } from './test-utils';\nimport { Foam } from '../core/model/foam';\nimport { FoamWorkspace } from '../core/model/workspace';\n\nLogger.setLevel('error');\n\n/**\n * Creates a minimal Foam mock with a workspace rooted at all VS Code workspace folders.\n * Shared by spec files that need a Foam instance without a full bootstrap.\n */\nexport function makeFoamMock() {\n  const roots = vscode.workspace.workspaceFolders.map(f =>\n    fromVsCodeUri(f.uri)\n  );\n  return { workspace: new FoamWorkspace(roots) } as any;\n}\n\n/**\n * Deletes all files in the workspace, except for .vscode and .keep files.\n * Optionally waits for the Foam workspace to be empty after the cleanup.\n *\n * @param timeout if > 0, wait for the Foam workspace to be empty after cleanup, with this timeout\n * @returns a promise that resolves when the cleanup (and optional wait) is complete\n */\nexport const cleanWorkspace = async (timeout = 0) => {\n  const files = await vscode.workspace.findFiles('**', '{.vscode,.keep}');\n  await Promise.all(files.map(f => deleteFile(fromVsCodeUri(f))));\n  if (timeout > 0) {\n    await waitForEmptyFoamWorkspace(timeout);\n  }\n};\n\nexport const showInEditor = async (uri: URI) => {\n  const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));\n  const editor = await vscode.window.showTextDocument(doc);\n  return { doc, editor };\n};\n\nexport const closeEditors = async () => {\n  await vscode.commands.executeCommand('workbench.action.closeAllEditors');\n  await wait(100);\n};\n\nexport const deleteFile = async (file: URI | { uri: URI }) => {\n  const uri = 'uri' in file ? file.uri : file;\n  try {\n    await vscode.workspace.fs.delete(toVsCodeUri(uri), {\n      recursive: true,\n    });\n  } catch (e) {\n    // ignore\n  }\n};\n\n/**\n * Generates a URI within the workspace, either randomly\n * or by using the provided path components\n *\n * @param filepath optional path components for the URI\n * @returns a URI within the workspace\n */\nexport const getUriInWorkspace = (...filepath: string[]) => {\n  const rootUri = fromVsCodeUri(vscode.workspace.workspaceFolders[0].uri);\n  filepath = filepath.length > 0 ? filepath : [randomString() + '.md'];\n  const uri = rootUri.joinPath(...filepath);\n  return uri;\n};\n\nexport const getFoamFromVSCode = async (): Promise<Foam> => {\n  // In test environment, try different extension IDs\n  const extension = vscode.extensions.getExtension('foam.foam-vscode');\n\n  const exports = extension.isActive\n    ? extension.exports\n    : await extension.activate();\n  if (!exports || !exports.foam) {\n    throw new Error('Foam not available in extension exports');\n  }\n\n  return exports.foam;\n};\n\nexport const waitForNoteInFoamWorkspace = async (uri: URI, timeout = 5000) => {\n  const start = Date.now();\n  const foam = await getFoamFromVSCode();\n  const workspace = foam.workspace;\n\n  while (Date.now() - start < timeout) {\n    if (workspace.find(uri.path)) {\n      return true;\n    }\n    await wait(100);\n  }\n  throw new Error(\n    `Timeout waiting for note ${uri.toString()} in Foam workspace`\n  );\n};\n\nexport const waitForNoteRemovedFromFoamWorkspace = async (\n  uri: URI,\n  timeout = 5000\n) => {\n  const start = Date.now();\n  const foam = await getFoamFromVSCode();\n  const workspace = foam.workspace;\n\n  while (Date.now() - start < timeout) {\n    if (!workspace.find(uri.path)) {\n      return true;\n    }\n    await wait(100);\n  }\n  throw new Error(\n    `Timeout waiting for note ${uri.toString()} to be removed from Foam workspace`\n  );\n};\n\nexport const waitForEmptyFoamWorkspace = async (timeout = 5000) => {\n  const start = Date.now();\n  const foam = await getFoamFromVSCode();\n  const workspace = foam.workspace;\n\n  while (Date.now() - start < timeout) {\n    if (workspace.list().length === 0) {\n      return true;\n    }\n    await wait(100);\n  }\n  throw new Error(\n    `Timeout waiting for Foam workspace to be empty (${\n      workspace.list().length\n    } resources remaining)`\n  );\n};\n\n/**\n * Creates a file with a some content.\n *\n * @param content the file content\n * @param path relative file path\n * @returns an object containing various information about the file created\n */\nexport const createFile = async (content: string, filepath: string[] = []) => {\n  const uri = getUriInWorkspace(...filepath);\n  const filenameComponents = path.parse(uri.toFsPath());\n  await vscode.workspace.fs.writeFile(\n    toVsCodeUri(uri),\n    new TextEncoder().encode(content)\n  );\n  return { uri, content, ...filenameComponents };\n};\n\nexport const renameFile = (from: URI, to: URI) => {\n  const edit = new vscode.WorkspaceEdit();\n  edit.renameFile(toVsCodeUri(from), toVsCodeUri(to));\n  return vscode.workspace.applyEdit(edit);\n};\n\nconst decoder = new TextDecoder('utf-8');\nexport const readFile = async (uri: URI) => {\n  const content = await vscode.workspace.fs.readFile(toVsCodeUri(uri));\n  return decoder.decode(content);\n};\n\nexport const createNote = (r: Resource) => {\n  const content = `# ${r.title}\n\n  some content and ${r.links\n    .map(l => l.rawText)\n    .join(' some content between links.\\n')}\n  last line.\n`;\n  return vscode.workspace.fs.writeFile(\n    toVsCodeUri(r.uri),\n    new TextEncoder().encode(content)\n  );\n};\n\nexport const runCommand = async <T>(command: string, args: T = undefined) =>\n  vscode.commands.executeCommand(command, args);\n\n/**\n * Runs a function with a modified configuration and\n * restores the original configuration afterwards\n *\n * @param key the key of the configuration to modify\n * @param value the value to set the configuration to\n * @param fn the function to execute\n */\nexport const withModifiedConfiguration = async (key, value, fn: () => void) => {\n  const old = vscode.workspace.getConfiguration().inspect(key);\n  await vscode.workspace.getConfiguration().update(key, value);\n  await fn();\n  await vscode.workspace.getConfiguration().update(key, old.workspaceValue);\n};\n\n/**\n * Runs a function with a modified Foam configuration and\n * restores the original configuration afterwards\n *\n * @param key the key of the Foam configuration to modify\n * @param value the value to set the configuration to\n * @param fn the function to execute\n */\nexport const withModifiedFoamConfiguration = (key, value, fn: () => void) =>\n  withModifiedConfiguration(`foam.${key}`, value, fn);\n\n/**\n * Utility function to check if two URIs are the same.\n * It has the goal of supporting Uri and URI, and dealing with\n * inconsistencies in the way they are represented (especially the\n * drive letter in Windows)\n *\n * @param actual the actual value\n * @param expected the expected value\n */\nexport const expectSameUri = (\n  actual: vscode.Uri | URI,\n  expected: vscode.Uri | URI\n) => {\n  expect(actual.path.toLocaleLowerCase()).toEqual(\n    expected.path.toLocaleLowerCase()\n  );\n};\n"
  },
  {
    "path": "packages/foam-vscode/src/test/test-utils.ts",
    "content": "/*\n * This file should not depend on VS Code as it's used for unit tests\n */\nimport fs from 'fs';\nimport { Logger } from '../core/utils/log';\nimport { Range } from '../core/model/range';\nimport { URI } from '../core/model/uri';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { MarkdownResourceProvider } from '../core/services/markdown-provider';\nimport { Resource } from '../core/model/note';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport { IDataStore } from '../core/services/datastore';\n\nexport { default as waitForExpect } from 'wait-for-expect';\n\nLogger.setLevel('error');\n\n/**\n * An in-memory data store for testing that stores file content in a Map.\n * This allows tests to provide text content for notes without touching the filesystem.\n */\nexport class InMemoryDataStore implements IDataStore {\n  private files = new Map<string, string>();\n\n  /**\n   * Set the content for a file\n   */\n  set(uri: URI, content: string): void {\n    this.files.set(uri.path, content);\n  }\n\n  /**\n   * Delete a file\n   */\n  delete(uri: URI): void {\n    this.files.delete(uri.path);\n  }\n\n  /**\n   * Clear all files\n   */\n  clear(): void {\n    this.files.clear();\n  }\n\n  async list(): Promise<URI[]> {\n    return Array.from(this.files.keys()).map(path => URI.parse(path, 'file'));\n  }\n\n  async read(uri: URI): Promise<string | null> {\n    return this.files.get(uri.path) ?? null;\n  }\n}\n\nexport const TEST_DATA_DIR = URI.file(__dirname).joinPath(\n  '..',\n  '..',\n  'test-data'\n);\n\nconst position = Range.create(0, 0, 0, 100);\n\n/**\n * Turns a string into a URI\n * The goal of this function is to make sure we are consistent in the\n * way we generate URIs (and therefore IDs) across the tests\n */\nexport const strToUri = URI.file;\n\nexport const createTestWorkspace = (\n  workspaceRoots: URI[] = [],\n  dataStore?: IDataStore,\n  directoryMode: 'resolve' | 'disabled' = 'resolve'\n) => {\n  const workspace = new FoamWorkspace(workspaceRoots);\n  const parser = createMarkdownParser();\n  const provider = new MarkdownResourceProvider(\n    dataStore ?? {\n      read: _ => Promise.resolve(''),\n      list: () => Promise.resolve([]),\n    },\n    parser,\n    ['.md'],\n    directoryMode\n  );\n  workspace.registerProvider(provider);\n  return workspace;\n};\n\nexport const createTestNote = (params: {\n  uri: string;\n  title?: string;\n  links?: Array<{ slug: string; definitionUrl?: string } | { to: string }>;\n  tags?: string[];\n  aliases?: string[];\n  sections?: string[];\n  root?: URI;\n  type?: string;\n  properties?: Record<string, unknown>;\n}): Resource => {\n  const root = params.root ?? URI.file('/');\n  return {\n    uri: root.resolve(params.uri),\n    type: params.type ?? 'note',\n    properties: params.properties ?? {},\n    title: params.title ?? strToUri(params.uri).getBasename(),\n    sections:\n      params.sections?.map(label => ({\n        label,\n        range: Range.create(0, 0, 1, 0),\n      })) ?? [],\n    blocks: [],\n    tags:\n      params.tags?.map(t => ({\n        label: t,\n        range: Range.create(0, 0, 0, 0),\n      })) ?? [],\n    aliases:\n      params.aliases?.map(a => ({\n        title: a,\n        range: Range.create(0, 0, 0, 0),\n      })) ?? [],\n    links: params.links\n      ? params.links.map((link, index) => {\n          const range = Range.create(\n            position.start.line + index,\n            position.start.character,\n            position.start.line + index,\n            position.end.character\n          );\n          return 'slug' in link\n            ? {\n                type: 'wikilink',\n                range: range,\n                rawText: `[[${link.slug}]]`,\n                isEmbed: false,\n                definition: link.definitionUrl\n                  ? {\n                      label: link.slug,\n                      url: link.definitionUrl,\n                      range: Range.create(0, 0, 0, 0),\n                    }\n                  : link.slug,\n              }\n            : {\n                type: 'link',\n                range: range,\n                rawText: `[link text](${link.to})`,\n                isEmbed: false,\n              };\n        })\n      : [],\n  };\n};\n\nconst testParser = createMarkdownParser();\n\n/**\n * Parses markdown text and returns the resulting Resource.\n * Use this when you need accurate ranges, sections, and links\n * (as opposed to createTestNote which constructs them manually).\n */\nexport const createNoteFromMarkdown = (\n  uri: string,\n  text: string,\n  root: URI = URI.file('/')\n): Resource => testParser.parse(root.resolve(uri), text);\n\nexport const wait = (ms: number) =>\n  new Promise(resolve => setTimeout(resolve, ms));\n\nconst chars = 'abcdefghijklmnopqrstuvwyxzABCDEFGHIJKLMNOPQRSTUVWYXZ1234567890';\nexport const randomString = (len = 5) =>\n  new Array(len)\n    .fill('')\n    .map(() => chars.charAt(Math.floor(Math.random() * chars.length)))\n    .join('');\n\nexport const getRandomURI = () =>\n  URI.file('/random-uri-root/' + randomString() + '.md');\n\n/** Use fs for reading files in units where vscode.workspace is unavailable */\nexport const readFileFromFs = async (uri: URI) =>\n  (await fs.promises.readFile(uri.toFsPath())).toString();\n"
  },
  {
    "path": "packages/foam-vscode/src/test/vscode-mock-extensions.test.ts",
    "content": "import * as vscode from './vscode-mock';\n\ndescribe('vscode-mock extensions API', () => {\n  it('should provide extensions.getExtension', () => {\n    expect(vscode.extensions).toBeDefined();\n    expect(vscode.extensions.getExtension).toBeDefined();\n  });\n\n  it('should return foam extension', () => {\n    const ext = vscode.extensions.getExtension('foam.foam-vscode');\n    expect(ext).toBeDefined();\n    expect(ext?.id).toBe('foam.foam-vscode');\n    expect(ext?.isActive).toBe(true);\n  });\n\n  it('should return undefined for unknown extensions', () => {\n    const ext = vscode.extensions.getExtension('unknown.extension');\n    expect(ext).toBeUndefined();\n  });\n\n  it('should provide foam instance through extension exports', async () => {\n    const ext = vscode.extensions.getExtension('foam.foam-vscode');\n    expect(ext?.exports).toBeDefined();\n    expect(ext?.exports.foam).toBeDefined();\n\n    // foam is a getter that returns a Promise\n    const foam = await ext?.exports.foam;\n    expect(foam).toBeDefined();\n    expect(foam.workspace).toBeDefined();\n    expect(foam.graph).toBeDefined();\n  });\n\n  it('should support activate() method', async () => {\n    const ext = vscode.extensions.getExtension('foam.foam-vscode');\n    expect(ext?.activate).toBeDefined();\n\n    const exports = await ext?.activate();\n    expect(exports).toBeDefined();\n    expect(exports.foam).toBeDefined();\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/test/vscode-mock.ts",
    "content": "/**\n * Mock implementation of VS Code API for testing\n * Reuses existing Foam implementations where possible\n */\n\nimport * as os from 'os';\nimport * as fs from 'fs';\nimport * as path from 'path';\nimport { Position as FoamPosition } from '../core/model/position';\nimport { Range as FoamRange } from '../core/model/range';\nimport { URI } from '../core/model/uri';\nimport { Logger } from '../core/utils/log';\nimport { TextEdit as FoamTextEdit } from '../core/services/text-edit';\nimport * as foamCommands from '../features/commands';\nimport refactorActivate from '../features/refactor';\nimport { Foam, bootstrap } from '../core/model/foam';\nimport { createMarkdownParser } from '../core/services/markdown-parser';\nimport {\n  GenericDataStore,\n  AlwaysIncludeMatcher,\n  IWatcher,\n} from '../core/services/datastore';\nimport { MarkdownResourceProvider } from '../core/services/markdown-provider';\nimport { randomString } from './test-utils';\nimport micromatch from 'micromatch';\nimport { Emitter } from '../core/common/event';\n\ninterface Thenable<T> {\n  then<TResult>(\n    onfulfilled?: (value: T) => TResult | Thenable<TResult>,\n    onrejected?: (reason: any) => TResult | Thenable<TResult>\n  ): Thenable<TResult>;\n  then<TResult>(\n    onfulfilled?: (value: T) => TResult | Thenable<TResult>,\n    onrejected?: (reason: any) => void\n  ): Thenable<TResult>;\n}\n\n// ===== Basic VS Code Types =====\n\nexport class Position implements FoamPosition {\n  public readonly line: number;\n  public readonly character: number;\n  constructor(line: number, character: number) {\n    this.line = line;\n    this.character = character;\n  }\n  static create(line: number, character: number): Position {\n    return new Position(line, character);\n  }\n\n  // Instance methods\n  compareTo(other: Position): number {\n    if (this.line < other.line) return -1;\n    if (this.line > other.line) return 1;\n    if (this.character < other.character) return -1;\n    if (this.character > other.character) return 1;\n    return 0;\n  }\n\n  isAfter(other: Position): boolean {\n    return this.compareTo(other) > 0;\n  }\n\n  isAfterOrEqual(other: Position): boolean {\n    return this.compareTo(other) >= 0;\n  }\n\n  isBefore(other: Position): boolean {\n    return this.compareTo(other) < 0;\n  }\n\n  isBeforeOrEqual(other: Position): boolean {\n    return this.compareTo(other) <= 0;\n  }\n\n  isEqual(other: Position): boolean {\n    return this.compareTo(other) === 0;\n  }\n\n  translate(lineDelta?: number, characterDelta?: number): Position;\n  // eslint-disable-next-line no-dupe-class-members\n  translate(change: { lineDelta?: number; characterDelta?: number }): Position;\n  // eslint-disable-next-line no-dupe-class-members\n  translate(\n    lineDeltaOrChange?:\n      | number\n      | { lineDelta?: number; characterDelta?: number },\n    characterDelta?: number\n  ): Position {\n    let lineDelta: number;\n    let charDelta: number;\n\n    if (typeof lineDeltaOrChange === 'object') {\n      lineDelta = lineDeltaOrChange.lineDelta ?? 0;\n      charDelta = lineDeltaOrChange.characterDelta ?? 0;\n    } else {\n      lineDelta = lineDeltaOrChange ?? 0;\n      charDelta = characterDelta ?? 0;\n    }\n\n    return new Position(this.line + lineDelta, this.character + charDelta);\n  }\n\n  with(line?: number, character?: number): Position;\n  // eslint-disable-next-line no-dupe-class-members\n  with(change: { line?: number; character?: number }): Position;\n  // eslint-disable-next-line no-dupe-class-members\n  with(\n    lineOrChange?: number | { line?: number; character?: number },\n    character?: number\n  ): Position {\n    let line: number;\n    let char: number;\n\n    if (typeof lineOrChange === 'object') {\n      line = lineOrChange.line ?? this.line;\n      char = lineOrChange.character ?? this.character;\n    } else {\n      line = lineOrChange ?? this.line;\n      char = character ?? this.character;\n    }\n\n    return new Position(line, char);\n  }\n\n  // Static helper methods\n  static isAfter(a: Position, b: Position): boolean {\n    return a.isAfter(b);\n  }\n\n  static isAfterOrEqual(a: Position, b: Position): boolean {\n    return a.isAfterOrEqual(b);\n  }\n\n  static isBefore(a: Position, b: Position): boolean {\n    return a.isBefore(b);\n  }\n\n  static isBeforeOrEqual(a: Position, b: Position): boolean {\n    return a.isBeforeOrEqual(b);\n  }\n\n  static isEqual(a: Position, b: Position): boolean {\n    return a.isEqual(b);\n  }\n\n  static compareTo(a: Position, b: Position): number {\n    return a.compareTo(b);\n  }\n}\n\n// VS Code Range class\nexport class Range implements FoamRange {\n  public readonly start: Position;\n  public readonly end: Position;\n\n  constructor(start: Position, end: Position);\n  constructor(\n    startLine: number,\n    startCharacter: number,\n    endLine: number,\n    endCharacter: number\n  );\n  constructor(\n    startOrLine: Position | number,\n    endOrCharacter: Position | number,\n    endLine?: number,\n    endCharacter?: number\n  ) {\n    if (typeof startOrLine === 'number') {\n      this.start = new Position(startOrLine, endOrCharacter as number);\n      this.end = new Position(endLine!, endCharacter!);\n    } else {\n      this.start = startOrLine;\n      this.end = endOrCharacter as Position;\n    }\n  }\n\n  // Add static methods that were being used by other parts of the code\n  static create(\n    startLine: number,\n    startChar: number,\n    endLine?: number,\n    endChar?: number\n  ): Range {\n    return new Range(\n      startLine,\n      startChar,\n      endLine ?? startLine,\n      endChar ?? startChar\n    );\n  }\n\n  static createFromPosition(start: Position, end?: Position): Range {\n    return new Range(start, end ?? start);\n  }\n}\n\n// Create VS Code-compatible Uri interface that wraps Foam's URI\nexport interface Uri {\n  readonly scheme: string;\n  readonly authority: string;\n  readonly path: string;\n  readonly query: string;\n  readonly fragment: string;\n  readonly fsPath: string;\n\n  with(change: {\n    scheme?: string;\n    authority?: string;\n    path?: string;\n    query?: string;\n    fragment?: string;\n  }): Uri;\n\n  toString(): string;\n  toJSON(): any;\n}\n\n// Adapter to convert Foam URI to VS Code Uri\nexport function createVSCodeUri(foamUri: URI): Uri {\n  const uri: Uri = Object.defineProperties(\n    {\n      scheme: foamUri.scheme,\n      authority: foamUri.authority,\n      path: foamUri.path,\n      query: foamUri.query,\n      fragment: foamUri.fragment,\n      fsPath: foamUri.toFsPath(),\n    },\n    {\n      with: {\n        value(change: Parameters<Uri['with']>[0]) {\n          return createVSCodeUri(foamUri.with(change));\n        },\n        enumerable: false,\n      },\n      toString: {\n        value() {\n          return foamUri.toString();\n        },\n        enumerable: false,\n      },\n      toJSON: {\n        value() {\n          return {\n            scheme: foamUri.scheme,\n            authority: foamUri.authority,\n            path: foamUri.path,\n            query: foamUri.query,\n            fragment: foamUri.fragment,\n            fsPath: foamUri.toFsPath(),\n          };\n        },\n        enumerable: false,\n      },\n    }\n  ) as Uri;\n  return uri;\n}\n\n/**\n * Convert VS Code Uri to Foam URI\n */\nexport function fromVsCodeUri(vsCodeUri: Uri): URI {\n  return URI.file(vsCodeUri.fsPath);\n}\n\n// VS Code Uri static methods\n// eslint-disable-next-line @typescript-eslint/no-redeclare\nexport const Uri = {\n  file(path: string): Uri {\n    return createVSCodeUri(URI.file(path));\n  },\n\n  parse(value: string): Uri {\n    return createVSCodeUri(URI.parse(value, 'file'));\n  },\n\n  from(components: {\n    scheme: string;\n    authority?: string;\n    path?: string;\n    query?: string;\n    fragment?: string;\n  }): Uri {\n    // Create URI from components\n    const uriString = `${components.scheme}://${components.authority || ''}${\n      components.path || ''\n    }${components.query ? '?' + components.query : ''}${\n      components.fragment ? '#' + components.fragment : ''\n    }`;\n    return createVSCodeUri(URI.parse(uriString, 'file'));\n  },\n\n  joinPath(base: Uri, ...pathSegments: string[]): Uri {\n    const baseUri = URI.parse(base.toString(), 'file');\n    return createVSCodeUri(baseUri.joinPath(...pathSegments));\n  },\n};\n\n// VS Code Location class\nexport class Location {\n  constructor(public uri: Uri, public range: Range) {}\n}\n\n// VS Code SymbolKind enum\nexport enum SymbolKind {\n  File = 0,\n  Module = 1,\n  Namespace = 2,\n  Package = 3,\n  Class = 4,\n  Method = 5,\n  Property = 6,\n  Field = 7,\n  Constructor = 8,\n  Enum = 9,\n  Interface = 10,\n  Function = 11,\n  Variable = 12,\n  Constant = 13,\n  String = 14,\n  Number = 15,\n  Boolean = 16,\n  Array = 17,\n  Object = 18,\n  Key = 19,\n  Null = 20,\n  EnumMember = 21,\n  Struct = 22,\n  Event = 23,\n  Operator = 24,\n  TypeParameter = 25,\n}\n\n// VS Code SymbolInformation class\nexport class SymbolInformation {\n  constructor(\n    public name: string,\n    public kind: SymbolKind,\n    public containerName: string,\n    public location: Location\n  ) {}\n}\n\n// Selection extends Range\nexport class Selection extends Range {\n  public readonly anchor: Position;\n  public readonly active: Position;\n\n  constructor(anchor: Position, active: Position);\n  constructor(\n    anchorLine: number,\n    anchorCharacter: number,\n    activeLine: number,\n    activeCharacter: number\n  );\n  constructor(\n    anchorOrLine: Position | number,\n    activeOrCharacter: Position | number,\n    activeLine?: number,\n    activeCharacter?: number\n  ) {\n    let anchor: Position;\n    let active: Position;\n\n    if (typeof anchorOrLine === 'number') {\n      anchor = new Position(anchorOrLine, activeOrCharacter as number);\n      active = new Position(activeLine!, activeCharacter!);\n    } else {\n      anchor = anchorOrLine;\n      active = activeOrCharacter as Position;\n    }\n    super(anchor, active);\n    this.anchor = anchor;\n    this.active = active;\n  }\n\n  get isReversed(): boolean {\n    return Position.isAfter(this.anchor, this.active);\n  }\n\n  get isEmpty(): boolean {\n    return Position.isEqual(this.anchor, this.active);\n  }\n}\n\n// Basic enums\nexport enum EndOfLine {\n  LF = 1,\n  CRLF = 2,\n}\n\nexport enum ViewColumn {\n  Active = -1,\n  Beside = -2,\n  One = 1,\n  Two = 2,\n  Three = 3,\n}\n\nexport enum FileType {\n  Unknown = 0,\n  File = 1,\n  Directory = 2,\n  SymbolicLink = 64,\n}\n\nexport enum CompletionItemKind {\n  Text = 0,\n  Method = 1,\n  Function = 2,\n  Constructor = 3,\n  Field = 4,\n  Variable = 5,\n  Class = 6,\n  Interface = 7,\n  Module = 8,\n  Property = 9,\n  Unit = 10,\n  Value = 11,\n  Enum = 12,\n  Keyword = 13,\n  Snippet = 14,\n  Color = 15,\n  File = 16,\n  Reference = 17,\n  Folder = 18,\n  EnumMember = 19,\n  Constant = 20,\n  Struct = 21,\n  Event = 22,\n  Operator = 23,\n  TypeParameter = 24,\n}\n\nexport enum DiagnosticSeverity {\n  Error = 0,\n  Warning = 1,\n  Information = 2,\n  Hint = 3,\n}\n\nexport enum ProgressLocation {\n  SourceControl = 1,\n  Window = 10,\n  Notification = 15,\n}\n\n// ===== Code Actions =====\n\nexport class CodeActionKind {\n  public static readonly QuickFix = new CodeActionKind('quickfix');\n  public static readonly Refactor = new CodeActionKind('refactor');\n  public static readonly RefactorExtract = new CodeActionKind(\n    'refactor.extract'\n  );\n  public static readonly RefactorInline = new CodeActionKind('refactor.inline');\n  public static readonly RefactorMove = new CodeActionKind('refactor.move');\n  public static readonly RefactorRewrite = new CodeActionKind(\n    'refactor.rewrite'\n  );\n  public static readonly Source = new CodeActionKind('source');\n  public static readonly SourceOrganizeImports = new CodeActionKind(\n    'source.organizeImports'\n  );\n  public static readonly SourceFixAll = new CodeActionKind('source.fixAll');\n\n  constructor(public readonly value: string) {}\n}\n\nexport class CodeAction {\n  public title: string;\n  public edit?: WorkspaceEdit;\n  public diagnostics?: any[];\n  public kind?: CodeActionKind;\n  public command?: any;\n  public isPreferred?: boolean;\n  public disabled?: { reason: string };\n\n  constructor(title: string, kind?: CodeActionKind) {\n    this.title = title;\n    this.kind = kind;\n  }\n}\n\n// ===== Completion Items =====\n\nexport class CompletionItem {\n  public label: string;\n  public kind?: CompletionItemKind;\n  public detail?: string;\n  public documentation?: string;\n  public sortText?: string;\n  public filterText?: string;\n  public insertText?: string;\n  public range?: Range;\n  public command?: any;\n  public textEdit?: any;\n  public additionalTextEdits?: any[];\n\n  constructor(label: string, kind?: CompletionItemKind) {\n    this.label = label;\n    this.kind = kind;\n  }\n}\n\nexport class CompletionList {\n  public isIncomplete: boolean;\n  public items: CompletionItem[];\n\n  constructor(items: CompletionItem[] = [], isIncomplete = false) {\n    this.items = items;\n    this.isIncomplete = isIncomplete;\n  }\n}\n\n// ===== Hover =====\n\nexport class MarkdownString {\n  public value: string;\n  public isTrusted?: boolean;\n\n  constructor(value?: string) {\n    this.value = value || '';\n  }\n\n  appendText(value: string): MarkdownString {\n    this.value += value;\n    return this;\n  }\n\n  appendMarkdown(value: string): MarkdownString {\n    this.value += value;\n    return this;\n  }\n\n  appendCodeblock(value: string, language?: string): MarkdownString {\n    this.value += `\\`\\`\\`${language || ''}\\n${value}\\n\\`\\`\\``;\n    return this;\n  }\n}\n\nexport class Hover {\n  public contents: (MarkdownString | string)[];\n  public range?: Range;\n\n  constructor(\n    contents: (MarkdownString | string)[] | MarkdownString | string,\n    range?: Range\n  ) {\n    if (Array.isArray(contents)) {\n      this.contents = contents;\n    } else {\n      this.contents = [contents];\n    }\n    this.range = range;\n  }\n}\n\n// ===== Tree Items =====\n\nexport class TreeItem {\n  public label?: string;\n  public id?: string;\n  public iconPath?: string | Uri | { light: string | Uri; dark: string | Uri };\n  public description?: string;\n  public tooltip?: string;\n  public command?: any;\n  public collapsibleState?: number;\n  public contextValue?: string;\n  public resourceUri?: Uri;\n\n  constructor(label: string, collapsibleState?: number) {\n    this.label = label;\n    this.collapsibleState = collapsibleState;\n  }\n}\n\nexport enum TreeItemCollapsibleState {\n  None = 0,\n  Collapsed = 1,\n  Expanded = 2,\n}\n\n// ===== Theme Classes =====\n\nexport class ThemeColor {\n  constructor(public readonly id: string) {}\n}\n\nexport class ThemeIcon {\n  public readonly id: string;\n  public readonly color?: ThemeColor;\n\n  constructor(id: string, color?: ThemeColor) {\n    this.id = id;\n    this.color = color;\n  }\n\n  static readonly File = new ThemeIcon('file');\n  static readonly Folder = new ThemeIcon('folder');\n}\n\n// ===== Event System =====\n\nexport interface Event<T> {\n  (listener: (e: T) => any, thisArg?: any): { dispose(): void };\n}\n\nexport interface Disposable {\n  dispose(): void;\n}\n\n// ===== Cancellation =====\n\nexport interface CancellationToken {\n  readonly isCancellationRequested: boolean;\n  readonly onCancellationRequested: Event<any>;\n}\n\nexport class CancellationTokenSource {\n  private _token: CancellationToken | undefined;\n  private _emitter: EventEmitter<any> | undefined;\n  private _isCancelled = false;\n\n  get token(): CancellationToken {\n    if (!this._token) {\n      this._emitter = new EventEmitter<any>();\n      this._token = {\n        isCancellationRequested: this._isCancelled,\n        onCancellationRequested: this._emitter.event,\n      };\n    }\n    return this._token;\n  }\n\n  cancel(): void {\n    if (!this._isCancelled) {\n      this._isCancelled = true;\n      if (this._emitter) {\n        this._emitter.fire(undefined);\n      }\n      // Update token state\n      if (this._token) {\n        (this._token as any).isCancellationRequested = true;\n      }\n    }\n  }\n\n  dispose(): void {\n    if (this._emitter) {\n      this._emitter.dispose();\n      this._emitter = undefined;\n    }\n    this._token = undefined;\n  }\n}\n\n// ===== Progress =====\n\nexport interface Progress<T> {\n  report(value: T): void;\n}\n\nexport class EventEmitter<T> {\n  private listeners: ((e: T) => any)[] = [];\n\n  get event(): Event<T> {\n    return (listener: (e: T) => any, thisArg?: any) => {\n      const boundListener = thisArg ? listener.bind(thisArg) : listener;\n      this.listeners.push(boundListener);\n      return {\n        dispose: () => {\n          const index = this.listeners.indexOf(boundListener);\n          if (index >= 0) {\n            this.listeners.splice(index, 1);\n          }\n        },\n      };\n    };\n  }\n\n  fire(data: T): void {\n    this.listeners.forEach(listener => {\n      try {\n        listener(data);\n      } catch (error) {\n        console.error('Error in event listener:', error);\n      }\n    });\n  }\n\n  dispose(): void {\n    this.listeners = [];\n  }\n}\n\n// ===== Diagnostics =====\n\nexport class Diagnostic {\n  public range: Range;\n  public message: string;\n  public severity: DiagnosticSeverity;\n  public source?: string;\n  public code?: string | number;\n  public relatedInformation?: any[];\n\n  constructor(range: Range, message: string, severity?: DiagnosticSeverity) {\n    this.range = range;\n    this.message = message;\n    this.severity = severity || DiagnosticSeverity.Error;\n  }\n}\n\n// ===== SnippetString =====\n\nexport class SnippetString {\n  public readonly value: string;\n\n  constructor(value?: string) {\n    this.value = value || '';\n  }\n\n  appendText(string: string): SnippetString {\n    return new SnippetString(this.value + string);\n  }\n\n  appendTabstop(number?: number): SnippetString {\n    return new SnippetString(this.value + `$${number || 0}`);\n  }\n\n  appendPlaceholder(\n    value: string | ((snippet: SnippetString) => void),\n    number?: number\n  ): SnippetString {\n    const placeholder = typeof value === 'string' ? value : '';\n    return new SnippetString(this.value + `\\${${number || 1}:${placeholder}}`);\n  }\n\n  appendChoice(values: string[], number?: number): SnippetString {\n    return new SnippetString(\n      this.value + `\\${${number || 1}|${values.join(',')}|}`\n    );\n  }\n\n  appendVariable(\n    name: string,\n    defaultValue: string | ((snippet: SnippetString) => void)\n  ): SnippetString {\n    const def = typeof defaultValue === 'string' ? defaultValue : '';\n    return new SnippetString(this.value + `\\${${name}:${def}}`);\n  }\n}\n\n// ===== Configuration =====\n\nexport interface WorkspaceConfiguration {\n  get<T>(section: string): T | undefined;\n  get<T>(section: string, defaultValue: T): T;\n  has(section: string): boolean;\n  inspect<T>(section: string):\n    | {\n        key: string;\n        defaultValue?: T;\n        globalValue?: T;\n        workspaceValue?: T;\n        workspaceFolderValue?: T;\n      }\n    | undefined;\n  update(\n    section: string,\n    value: any,\n    configurationTarget?: any\n  ): Thenable<void>;\n  [key: string]: any;\n}\n\nclass MockWorkspaceConfiguration implements WorkspaceConfiguration {\n  private _config: Map<string, any> = new Map();\n\n  get<T>(section: string, defaultValue?: T): T {\n    return this._config.get(section) ?? defaultValue;\n  }\n\n  has(section: string): boolean {\n    return this._config.has(section);\n  }\n\n  inspect<T>(section: string):\n    | {\n        key: string;\n        defaultValue?: T;\n        globalValue?: T;\n        workspaceValue?: T;\n        workspaceFolderValue?: T;\n      }\n    | undefined {\n    return {\n      key: section,\n      workspaceValue: this._config.get(section),\n    };\n  }\n\n  update(\n    section: string,\n    value: any,\n    configurationTarget?: any\n  ): Thenable<void> {\n    this._config.set(section, value);\n    return Promise.resolve();\n  }\n}\n\n// ===== Document Management =====\n\nexport interface TextLine {\n  readonly lineNumber: number;\n  readonly text: string;\n  readonly range: Range;\n  readonly rangeIncludingLineBreak: Range;\n  readonly firstNonWhitespaceCharacterIndex: number;\n  readonly isEmptyOrWhitespace: boolean;\n}\n\nexport interface TextDocument {\n  readonly uri: Uri;\n  readonly fileName: string;\n  readonly isUntitled: boolean;\n  readonly languageId: string;\n  readonly version: number;\n  readonly isDirty: boolean;\n  readonly isClosed: boolean;\n  readonly eol: EndOfLine;\n  readonly lineCount: number;\n\n  save(): Thenable<boolean>;\n  getText(range?: Range): string;\n  lineAt(line: number): TextLine;\n  lineAt(position: Position): TextLine;\n  offsetAt(position: Position): number;\n  positionAt(offset: number): Position;\n  validatePosition(position: Position): Position;\n  validateRange(range: Range): Range;\n  getWordRangeAtPosition(position: Position): Range | undefined;\n}\n\nclass MockTextDocument implements TextDocument {\n  public readonly uri: Uri;\n  public readonly fileName: string;\n  public readonly isUntitled: boolean = false;\n  public readonly languageId: string = 'markdown';\n  public readonly version: number = 1;\n  public readonly isDirty: boolean = false;\n  public readonly isClosed: boolean = false;\n  public readonly eol: EndOfLine = EndOfLine.LF;\n\n  private _content: string = '';\n  private _lines: string[] = [];\n\n  constructor(uri: Uri, content?: string) {\n    this.uri = uri;\n    this.fileName = uri.fsPath;\n\n    if (content !== undefined) {\n      this._content = content;\n      // Write the content to file if provided\n      try {\n        const existed = fs.existsSync(uri.fsPath);\n        const dir = path.dirname(uri.fsPath);\n        if (!fs.existsSync(dir)) {\n          fs.mkdirSync(dir, { recursive: true });\n        }\n        fs.writeFileSync(uri.fsPath, content);\n\n        // Manually fire watcher events (can't use async workspace.fs in constructor)\n        for (const watcher of mockState.fileWatchers) {\n          if (existed) {\n            watcher._fireChange(uri);\n          } else {\n            watcher._fireCreate(uri);\n          }\n        }\n      } catch (error) {\n        // Ignore write errors in mock\n      }\n    } else {\n      // Try to read from file system\n      try {\n        this._content = fs.readFileSync(uri.fsPath, 'utf8');\n      } catch {\n        this._content = '';\n      }\n    }\n\n    this._lines = this._content.split(/\\r?\\n/);\n  }\n\n  get lineCount(): number {\n    return Math.max(1, this._lines.length);\n  }\n\n  async save(): Promise<boolean> {\n    try {\n      await fs.promises.writeFile(this.uri.fsPath, this._content);\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  getText(range?: Range): string {\n    // use the range to get specific parts if needed\n    if (range) {\n      const startOffset = this.offsetAt(range.start);\n      const endOffset = this.offsetAt(range.end);\n      return this._content.slice(startOffset, endOffset);\n    }\n    return this._content;\n  }\n\n  lineAt(lineOrPosition: number | Position): TextLine {\n    const lineNumber =\n      typeof lineOrPosition === 'number' ? lineOrPosition : lineOrPosition.line;\n    const text = this._lines[lineNumber] || '';\n    const range = Range.create(lineNumber, 0, lineNumber, text.length);\n    const rangeIncludingLineBreak = Range.create(\n      lineNumber,\n      0,\n      lineNumber + 1,\n      0\n    );\n\n    return {\n      lineNumber,\n      text,\n      range,\n      rangeIncludingLineBreak,\n      firstNonWhitespaceCharacterIndex: text.search(/\\S/),\n      isEmptyOrWhitespace: text.trim().length === 0,\n    };\n  }\n\n  offsetAt(position: Position): number {\n    let offset = 0;\n    for (let i = 0; i < position.line && i < this._lines.length; i++) {\n      offset += this._lines[i].length + 1; // +1 for newline\n    }\n    return (\n      offset +\n      Math.min(position.character, this._lines[position.line]?.length || 0)\n    );\n  }\n\n  positionAt(offset: number): Position {\n    let currentOffset = 0;\n    for (let line = 0; line < this._lines.length; line++) {\n      const lineLength = this._lines[line].length;\n      if (currentOffset + lineLength >= offset) {\n        return Position.create(line, offset - currentOffset);\n      }\n      currentOffset += lineLength + 1; // +1 for newline\n    }\n    return Position.create(\n      this._lines.length - 1,\n      this._lines[this._lines.length - 1]?.length || 0\n    );\n  }\n\n  validatePosition(position: Position): Position {\n    const line = Math.max(0, Math.min(position.line, this.lineCount - 1));\n    const character = Math.max(\n      0,\n      Math.min(position.character, this._lines[line]?.length || 0)\n    );\n    return Position.create(line, character);\n  }\n\n  validateRange(range: Range): Range {\n    const start = this.validatePosition(range.start);\n    const end = this.validatePosition(range.end);\n    return Range.createFromPosition(start, end);\n  }\n\n  getWordRangeAtPosition(position: Position): Range | undefined {\n    const line = this._lines[position.line];\n    if (!line) return undefined;\n\n    const wordRegex = /\\w+/g;\n    let match;\n    while ((match = wordRegex.exec(line)) !== null) {\n      const start = Position.create(position.line, match.index);\n      const end = Position.create(position.line, match.index + match[0].length);\n      if (\n        Position.isBeforeOrEqual(start, position) &&\n        Position.isAfterOrEqual(end, position)\n      ) {\n        return Range.createFromPosition(start, end);\n      }\n    }\n    return undefined;\n  }\n\n  // Internal method to update content\n  _updateContent(content: string): void {\n    this._content = content;\n    this._lines = content.split(/\\r?\\n/);\n    // Write the content to file immediately so it persists\n    try {\n      const dir = path.dirname(this.uri.fsPath);\n      if (!fs.existsSync(dir)) {\n        fs.mkdirSync(dir, { recursive: true });\n      }\n      fs.writeFileSync(this.uri.fsPath, content);\n    } catch (error) {\n      Logger.error('vscode-mock: Failed to write file', error);\n      throw error;\n    }\n  }\n}\n\nexport interface TextEditor {\n  readonly document: TextDocument;\n  selection: Selection;\n  selections: Selection[];\n  readonly visibleRanges: Range[];\n  readonly viewColumn: ViewColumn | undefined;\n\n  edit(callback: (editBuilder: any) => void): Thenable<boolean>;\n  insertSnippet(snippet: any): Thenable<boolean>;\n  setDecorations(decorationType: any, ranges: Range[]): void;\n  revealRange(range: Range): void;\n  show(column?: ViewColumn): void;\n  hide(): void;\n}\n\nclass MockTextEditor implements TextEditor {\n  public readonly document: TextDocument;\n  public selection: Selection;\n  public selections: Selection[];\n  public readonly visibleRanges: Range[] = [];\n  public readonly viewColumn: ViewColumn | undefined;\n\n  constructor(document: TextDocument, viewColumn?: ViewColumn) {\n    this.document = document;\n    this.viewColumn = viewColumn;\n    this.selection = new Selection(0, 0, 0, 0);\n    this.selections = [this.selection];\n  }\n\n  async edit(callback: (editBuilder: any) => void): Promise<boolean> {\n    // Simplified edit implementation\n    const edits: { range: Range; newText: string }[] = [];\n    const editBuilder = {\n      replace: (range: Range, newText: string) => {\n        edits.push({ range, newText });\n      },\n    };\n    callback(editBuilder);\n\n    // Apply edits in reverse order to avoid offset issues\n    const document = this.document as MockTextDocument;\n    let content = document.getText();\n    edits\n      .sort(\n        (a, b) =>\n          document.offsetAt(b.range.start) - document.offsetAt(a.range.start)\n      )\n      .forEach(edit => {\n        const startOffset = document.offsetAt(edit.range.start);\n        const endOffset = document.offsetAt(edit.range.end);\n        content =\n          content.substring(0, startOffset) +\n          edit.newText +\n          content.substring(endOffset);\n      });\n\n    document._updateContent(content);\n    return true;\n  }\n\n  async insertSnippet(snippet: any): Promise<boolean> {\n    // Insert snippet at current selection\n    if (snippet && typeof snippet === 'object' && snippet.value) {\n      const text = snippet.value;\n      const document = this.document as MockTextDocument;\n\n      // Replace selection with snippet text\n      const startOffset = document.offsetAt(this.selection.start);\n      const endOffset = document.offsetAt(this.selection.end);\n      let content = document.getText();\n      content =\n        content.substring(0, startOffset) + text + content.substring(endOffset);\n\n      document._updateContent(content);\n\n      // Move cursor to end of inserted text\n      const newPosition = document.positionAt(startOffset + text.length);\n      this.selection = new Selection(newPosition, newPosition);\n      this.selections = [this.selection];\n    }\n    return true;\n  }\n\n  setDecorations(decorationType: any, ranges: Range[]): void {\n    // No-op for mock\n  }\n\n  revealRange(range: Range): void {\n    // No-op for mock\n  }\n\n  show(column?: ViewColumn): void {\n    // No-op for mock\n  }\n\n  hide(): void {\n    // No-op for mock\n  }\n}\n\n// ===== TextEdit =====\n\nexport class TextEdit {\n  constructor(public range: Range, public newText: string) {}\n\n  static replace(range: Range, newText: string): TextEdit {\n    return new TextEdit(range, newText);\n  }\n\n  static insert(position: Position, newText: string): TextEdit {\n    return new TextEdit(new Range(position, position), newText);\n  }\n\n  static delete(range: Range): TextEdit {\n    return new TextEdit(range, '');\n  }\n}\n\n// ===== WorkspaceEdit =====\n\nexport class WorkspaceEdit {\n  private _edits: Map<string, any[]> = new Map();\n\n  replace(uri: Uri, range: Range, newText: string): void {\n    const key = uri.toString();\n    if (!this._edits.has(key)) {\n      this._edits.set(key, []);\n    }\n    this._edits.get(key)!.push(new TextEdit(range, newText));\n  }\n\n  insert(uri: Uri, position: Position, newText: string): void {\n    const key = uri.toString();\n    if (!this._edits.has(key)) {\n      this._edits.set(key, []);\n    }\n    this._edits\n      .get(key)!\n      .push(new TextEdit(new Range(position, position), newText));\n  }\n\n  delete(uri: Uri, range: Range): void {\n    const key = uri.toString();\n    if (!this._edits.has(key)) {\n      this._edits.set(key, []);\n    }\n    this._edits.get(key)!.push(new TextEdit(range, ''));\n  }\n\n  renameFile(\n    oldUri: Uri,\n    newUri: Uri,\n    options?: { overwrite?: boolean; ignoreIfExists?: boolean }\n  ): void {\n    const key = oldUri.toString();\n    if (!this._edits.has(key)) {\n      this._edits.set(key, []);\n    }\n    this._edits.get(key)!.push({ type: 'rename', oldUri, newUri, options });\n  }\n\n  set(uri: Uri, edits: TextEdit[]): void {\n    const key = uri.toString();\n    if (!this._edits.has(key)) {\n      this._edits.set(key, []);\n    }\n    this._edits.get(key)!.push(...edits);\n  }\n\n  get(uri: Uri): TextEdit[] | undefined {\n    const key = uri.toString();\n    return this._edits.get(key) as TextEdit[] | undefined;\n  }\n\n  entries(): [Uri, TextEdit[]][] {\n    const result: [Uri, TextEdit[]][] = [];\n    for (const [key, edits] of this._edits) {\n      const uri = createVSCodeUri(URI.parse(key, 'file'));\n      result.push([\n        uri,\n        edits.filter(e => e instanceof TextEdit || e.range) as TextEdit[],\n      ]);\n    }\n    return result;\n  }\n\n  // Internal method to get edits for applying\n  _getEdits(): Map<string, any[]> {\n    return this._edits;\n  }\n\n  get size(): number {\n    return this._edits.size;\n  }\n}\n\n// ===== FileSystem Mock =====\n\nexport interface FileSystem {\n  readFile(uri: Uri): Thenable<Uint8Array>;\n  writeFile(uri: Uri, content: Uint8Array): Thenable<void>;\n  delete(uri: Uri, options?: { recursive?: boolean }): Thenable<void>;\n  stat(\n    uri: Uri\n  ): Thenable<{ type: number; size: number; mtime: number; ctime: number }>;\n  readDirectory(uri: Uri): Thenable<[string, number][]>;\n  createDirectory(uri: Uri): Thenable<void>;\n  copy(\n    source: Uri,\n    target: Uri,\n    options?: { overwrite?: boolean }\n  ): Thenable<void>;\n  rename(\n    source: Uri,\n    target: Uri,\n    options?: { overwrite?: boolean }\n  ): Thenable<void>;\n}\n\nclass MockFileSystem implements FileSystem {\n  async readFile(uri: Uri): Promise<Uint8Array> {\n    const content = await fs.promises.readFile(uri.fsPath);\n    return new Uint8Array(content);\n  }\n\n  async writeFile(uri: Uri, content: Uint8Array): Promise<void> {\n    // Check if file exists before writing\n    const existed = await this.exists(uri);\n\n    // Ensure directory exists\n    const dir = path.dirname(uri.fsPath);\n    await fs.promises.mkdir(dir, { recursive: true });\n    await fs.promises.writeFile(uri.fsPath, content);\n\n    // Fire watcher events\n    for (const watcher of mockState.fileWatchers) {\n      if (existed) {\n        watcher._fireChange(uri);\n      } else {\n        watcher._fireCreate(uri);\n      }\n    }\n  }\n\n  private async exists(uri: Uri): Promise<boolean> {\n    try {\n      await fs.promises.access(uri.fsPath);\n      return true;\n    } catch {\n      return false;\n    }\n  }\n\n  async delete(uri: Uri, options?: { recursive?: boolean }): Promise<void> {\n    // Fire onWillDeleteFiles listeners before deleting, so handlers can\n    // clean up workspace state synchronously (mirrors VS Code's behaviour).\n    if (mockState.onWillDeleteFilesListeners.length > 0) {\n      const event = { files: [uri] };\n      for (const listener of mockState.onWillDeleteFilesListeners) {\n        const result = listener(event);\n        if (result && typeof result.then === 'function') {\n          await result;\n        }\n      }\n    }\n\n    if (options?.recursive) {\n      // Use rmdir with recursive option for older Node.js versions\n      try {\n        await fs.promises.rmdir(uri.fsPath, { recursive: true });\n      } catch {\n        // Fallback for very old Node.js versions\n        await fs.promises.unlink(uri.fsPath);\n      }\n    } else {\n      await fs.promises.unlink(uri.fsPath);\n    }\n\n    // Fire watcher events\n    for (const watcher of mockState.fileWatchers) {\n      watcher._fireDelete(uri);\n    }\n  }\n\n  async stat(\n    uri: Uri\n  ): Promise<{ type: number; size: number; mtime: number; ctime: number }> {\n    const stats = await fs.promises.stat(uri.fsPath);\n    return {\n      type: stats.isFile() ? 1 : stats.isDirectory() ? 2 : 0,\n      size: stats.size,\n      mtime: stats.mtime.getTime(),\n      ctime: stats.ctime.getTime(),\n    };\n  }\n\n  async readDirectory(uri: Uri): Promise<[string, number][]> {\n    const entries = await fs.promises.readdir(uri.fsPath, {\n      withFileTypes: true,\n    });\n    return entries.map(entry => [\n      entry.name,\n      entry.isFile() ? 1 : entry.isDirectory() ? 2 : 0,\n    ]);\n  }\n\n  async createDirectory(uri: Uri): Promise<void> {\n    await fs.promises.mkdir(uri.fsPath, { recursive: true });\n  }\n\n  async copy(\n    source: Uri,\n    target: Uri,\n    options?: { overwrite?: boolean }\n  ): Promise<void> {\n    await fs.promises.copyFile(source.fsPath, target.fsPath);\n  }\n\n  async rename(\n    source: Uri,\n    target: Uri,\n    options?: { overwrite?: boolean }\n  ): Promise<void> {\n    await fs.promises.rename(source.fsPath, target.fsPath);\n\n    // Fire watcher events (rename = delete + create)\n    for (const watcher of mockState.fileWatchers) {\n      watcher._fireDelete(source);\n      watcher._fireCreate(target);\n    }\n  }\n}\n\n// ===== File System Watcher =====\n\nexport interface FileSystemWatcher extends Disposable {\n  onDidCreate: Event<Uri>;\n  onDidChange: Event<Uri>;\n  onDidDelete: Event<Uri>;\n  ignoreCreateEvents: boolean;\n  ignoreChangeEvents: boolean;\n  ignoreDeleteEvents: boolean;\n}\n\nclass MockFileSystemWatcher implements FileSystemWatcher {\n  private onDidCreateEmitter = new Emitter<Uri>();\n  private onDidChangeEmitter = new Emitter<Uri>();\n  private onDidDeleteEmitter = new Emitter<Uri>();\n\n  onDidCreate = this.onDidCreateEmitter.event;\n  onDidChange = this.onDidChangeEmitter.event;\n  onDidDelete = this.onDidDeleteEmitter.event;\n\n  ignoreCreateEvents = false;\n  ignoreChangeEvents = false;\n  ignoreDeleteEvents = false;\n\n  constructor(private pattern: string) {\n    // Register this watcher in mockState (will be added to mockState)\n    if (mockState.fileWatchers) {\n      mockState.fileWatchers.push(this);\n    }\n  }\n\n  // Internal methods called by MockFileSystem\n  _fireCreate(uri: Uri) {\n    if (!this.ignoreCreateEvents && this.matches(uri)) {\n      this.onDidCreateEmitter.fire(uri);\n    }\n  }\n\n  _fireChange(uri: Uri) {\n    if (!this.ignoreChangeEvents && this.matches(uri)) {\n      this.onDidChangeEmitter.fire(uri);\n    }\n  }\n\n  _fireDelete(uri: Uri) {\n    if (!this.ignoreDeleteEvents && this.matches(uri)) {\n      this.onDidDeleteEmitter.fire(uri);\n    }\n  }\n\n  private matches(uri: Uri): boolean {\n    const workspaceFolder = mockState.workspaceFolders[0];\n    if (!workspaceFolder) return false;\n\n    const relativePath = path.relative(workspaceFolder.uri.fsPath, uri.fsPath);\n    // Use micromatch (already imported) for glob matching\n    return micromatch.isMatch(relativePath, this.pattern);\n  }\n\n  dispose() {\n    if (mockState.fileWatchers) {\n      const index = mockState.fileWatchers.indexOf(this);\n      if (index >= 0) {\n        mockState.fileWatchers.splice(index, 1);\n      }\n    }\n    this.onDidCreateEmitter.dispose();\n    this.onDidChangeEmitter.dispose();\n    this.onDidDeleteEmitter.dispose();\n  }\n}\n\n// ===== Workspace Folder =====\n\nexport interface WorkspaceFolder {\n  readonly uri: Uri;\n  readonly name: string;\n  readonly index: number;\n}\n\n// ===== Extension Context =====\n\nexport interface ExtensionContext {\n  subscriptions: Disposable[];\n  workspaceState: any;\n  globalState: any;\n  extensionPath: string;\n  extensionUri: Uri;\n  storageUri: Uri | undefined;\n  globalStorageUri: Uri;\n  logUri: Uri;\n  secrets: any;\n  environmentVariableCollection: any;\n  asAbsolutePath(relativePath: string): string;\n  storagePath: string | undefined;\n  globalStoragePath: string;\n  logPath: string;\n  extensionMode: number;\n  extension: any;\n  languageModelAccessInformation: any;\n}\n\nfunction createMockExtensionContext(): ExtensionContext {\n  return {\n    subscriptions: [],\n    workspaceState: {\n      get: () => undefined,\n      update: () => Promise.resolve(),\n    },\n    globalState: {\n      get: () => undefined,\n      update: () => Promise.resolve(),\n    },\n    extensionPath: '/mock/extension/path',\n    extensionUri: createVSCodeUri(\n      URI.parse('file:///mock/extension/path', null)\n    ),\n    storageUri: undefined,\n    globalStorageUri: createVSCodeUri(\n      URI.parse('file:///mock/global/storage', null)\n    ),\n    logUri: createVSCodeUri(URI.parse('file:///mock/logs', null)),\n    secrets: {\n      get: () => Promise.resolve(undefined),\n      store: () => Promise.resolve(),\n      delete: () => Promise.resolve(),\n    },\n    environmentVariableCollection: {\n      clear: () => {},\n      get: () => undefined,\n      set: () => {},\n      delete: () => {},\n    },\n    asAbsolutePath: (relativePath: string) =>\n      path.join('/mock/extension/path', relativePath),\n    storagePath: '/mock/storage',\n    globalStoragePath: '/mock/global/storage',\n    logPath: '/mock/logs',\n    extensionMode: 1,\n    extension: {\n      id: 'foam.foam-vscode',\n      packageJSON: {},\n    },\n    languageModelAccessInformation: {\n      onDidChange: () => ({ dispose: () => {} }),\n      canSendRequest: () => undefined,\n    },\n  };\n}\n\n// ===== Extension API =====\n\nexport interface Extension<T> {\n  id: string;\n  extensionPath: string;\n  isActive: boolean;\n  packageJSON: any;\n  exports: T;\n  activate(): Thenable<T>;\n}\n\nclass MockExtension<T> implements Extension<T> {\n  constructor(\n    public id: string,\n    public exports: T,\n    public isActive: boolean = true\n  ) {}\n\n  extensionPath = '/mock/extension/path';\n  packageJSON = {};\n\n  activate(): Thenable<T> {\n    this.isActive = true;\n    return Promise.resolve(this.exports);\n  }\n}\n\n// ===== File System Helpers =====\n\nasync function collectFilesRecursively(dir: string): Promise<string[]> {\n  const files: string[] = [];\n  try {\n    const entries = await fs.promises.readdir(dir, { withFileTypes: true });\n    for (const entry of entries) {\n      const fullPath = path.join(dir, entry.name);\n      if (entry.isDirectory()) {\n        const subFiles = await collectFilesRecursively(fullPath);\n        files.push(...subFiles);\n      } else if (entry.isFile()) {\n        files.push(fullPath);\n      }\n    }\n  } catch {\n    // Ignore errors\n  }\n  return files;\n}\n\n// ===== Foam Commands Lazy Initialization =====\n\nclass TestFoam {\n  private static instance: Foam | null = null;\n\n  static async getInstance(): Promise<Foam> {\n    if (!TestFoam.instance) {\n      TestFoam.instance = await TestFoam.bootstrap();\n    }\n    return TestFoam.instance;\n  }\n\n  static async bootstrap(): Promise<Foam> {\n    const workspaceFolder = mockState.workspaceFolders[0];\n    if (!workspaceFolder) {\n      throw new Error('No workspace folder available for mock Foam');\n    }\n\n    // Create real file system implementations\n    const listFiles = async (): Promise<URI[]> => {\n      // Recursively find all markdown files in the workspace\n      const findMarkdownFiles = async (dir: string): Promise<URI[]> => {\n        const files: URI[] = [];\n        try {\n          const entries = await fs.promises.readdir(dir, {\n            withFileTypes: true,\n          });\n\n          for (const entry of entries) {\n            const fullPath = path.join(dir, entry.name);\n\n            if (entry.isDirectory()) {\n              const subFiles = await findMarkdownFiles(fullPath);\n              files.push(...subFiles);\n            } else if (entry.isFile() && entry.name.endsWith('.md')) {\n              files.push(URI.file(fullPath));\n            }\n          }\n        } catch (error) {\n          // Ignore errors accessing directories\n        }\n\n        return files;\n      };\n\n      return findMarkdownFiles(workspaceFolder.uri.fsPath);\n    };\n\n    const readFile = async (uri: URI): Promise<string> => {\n      try {\n        return await fs.promises.readFile(uri.toFsPath(), 'utf8');\n      } catch (error) {\n        Logger.debug(`Failed to read file ${uri.toString()}: ${error}`);\n        return '';\n      }\n    };\n\n    // Create services\n    const dataStore = new GenericDataStore(listFiles, readFile);\n    const parser = createMarkdownParser();\n    const matcher = new AlwaysIncludeMatcher(); // Accept all markdown files\n\n    // Create resource providers\n    const providers = [new MarkdownResourceProvider(dataStore, parser)];\n\n    // Create file watcher for automatic workspace updates\n    const vsCodeWatcher = workspace.createFileSystemWatcher('**/*');\n\n    // Convert VS Code Uri events to Foam URI events\n    const onDidCreateEmitter = new Emitter<URI>();\n    const onDidChangeEmitter = new Emitter<URI>();\n    const onDidDeleteEmitter = new Emitter<URI>();\n\n    vsCodeWatcher.onDidCreate(uri =>\n      onDidCreateEmitter.fire(fromVsCodeUri(uri))\n    );\n    vsCodeWatcher.onDidChange(uri =>\n      onDidChangeEmitter.fire(fromVsCodeUri(uri))\n    );\n    vsCodeWatcher.onDidDelete(uri =>\n      onDidDeleteEmitter.fire(fromVsCodeUri(uri))\n    );\n\n    const foamWatcher: IWatcher = {\n      onDidCreate: onDidCreateEmitter.event,\n      onDidChange: onDidChangeEmitter.event,\n      onDidDelete: onDidDeleteEmitter.event,\n    };\n\n    // Use the bootstrap function with file watcher\n    const foam = await bootstrap(\n      [fromVsCodeUri(workspaceFolder.uri)],\n      matcher,\n      foamWatcher,\n      dataStore,\n      parser,\n      providers,\n      '.md'\n    );\n\n    Logger.info('Mock Foam instance created with file watcher');\n    return foam;\n  }\n\n  static async reloadFoamWorkspace(): Promise<void> {\n    // Simple reload: clear workspace and reload all files\n    TestFoam.instance.workspace.clear();\n\n    // Re-read all markdown files from the filesystem\n    const files = await TestFoam.instance.services.dataStore.list();\n    for (const file of files) {\n      await TestFoam.instance.workspace.fetchAndSet(file);\n    }\n\n    TestFoam.instance.graph.update();\n    TestFoam.instance.tags.update();\n\n    Logger.debug(`Reloaded workspace with ${files.length} files`);\n  }\n\n  static dispose() {\n    if (TestFoam.instance) {\n      try {\n        TestFoam.instance.dispose();\n      } catch (error) {\n        // Ignore disposal errors\n      }\n      TestFoam.instance = null;\n    }\n  }\n}\n\nlet foamCommandsInitialized = false;\n\nasync function initializeFoamCommands(foam: Foam): Promise<void> {\n  if (foamCommandsInitialized) {\n    return;\n  }\n  foamCommandsInitialized = true;\n\n  const mockContext = createMockExtensionContext();\n\n  const foamPromise = Promise.resolve(foam);\n  // Initialize all command modules\n  // Commands that need Foam instance\n  await foamCommands.createNote(mockContext, foamPromise);\n  await foamCommands.janitorCommand(mockContext, foamPromise);\n  await foamCommands.openRandomNoteCommand(mockContext, foamPromise);\n  await foamCommands.openResource(mockContext, foamPromise);\n  await foamCommands.updateGraphCommand(mockContext, foamPromise);\n  await foamCommands.updateWikilinksCommand(mockContext, foamPromise);\n  await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise);\n  await foamCommands.convertLinksCommand(mockContext, foamPromise);\n  await foamCommands.buildEmbeddingsCommand(mockContext, foamPromise);\n  await foamCommands.openDailyNoteCommand(mockContext, foamPromise);\n  await foamCommands.openDatedNote(mockContext, foamPromise);\n\n  // Commands that only need context\n  await foamCommands.copyWithoutBracketsCommand(mockContext);\n  await foamCommands.createFromTemplateCommand(mockContext);\n  await foamCommands.createNewTemplate(mockContext);\n\n  // Features that register workspace event listeners\n  await refactorActivate(mockContext, foamPromise);\n\n  Logger.info('Foam commands initialized successfully in mock environment');\n}\n\n// ===== VS Code Namespaces =====\n\n// Global state\nconst mockState = {\n  activeTextEditor: undefined as TextEditor | undefined,\n  visibleTextEditors: [] as TextEditor[],\n  workspaceFolders: [] as WorkspaceFolder[],\n  commands: new Map<string, (...args: any[]) => any>(),\n  fileSystem: new MockFileSystem(),\n  configuration: new MockWorkspaceConfiguration(),\n  fileWatchers: [] as MockFileSystemWatcher[],\n  onWillRenameFilesListeners: [] as ((e: any) => any)[],\n  onDidRenameFilesListeners: [] as ((e: any) => any)[],\n  onWillDeleteFilesListeners: [] as ((e: any) => any)[],\n  openDocuments: new Map<string, MockTextDocument>(),\n};\n\n// Window namespace\nexport const window = {\n  get activeTextEditor(): TextEditor | undefined {\n    return mockState.activeTextEditor;\n  },\n\n  set activeTextEditor(editor: TextEditor | undefined) {\n    mockState.activeTextEditor = editor;\n  },\n\n  get visibleTextEditors(): TextEditor[] {\n    return mockState.visibleTextEditors;\n  },\n\n  async showInputBox(options?: {\n    value?: string;\n    prompt?: string;\n    placeHolder?: string;\n    password?: boolean;\n    validateInput?: (value: string) => string | undefined;\n  }): Promise<string | undefined> {\n    // This will be mocked in tests\n    return undefined;\n  },\n\n  async showQuickPick(items: any[], options?: any): Promise<any> {\n    throw new Error(\n      'showQuickPick not implemented - should be mocked in tests'\n    );\n  },\n\n  async showTextDocument(\n    documentOrUri: TextDocument | Uri,\n    options?: {\n      viewColumn?: ViewColumn;\n      preserveFocus?: boolean;\n      preview?: boolean;\n      selection?: Range;\n    }\n  ): Promise<TextEditor> {\n    let document: TextDocument;\n\n    if ('uri' in documentOrUri) {\n      document = documentOrUri;\n    } else {\n      document = await workspace.openTextDocument(documentOrUri);\n    }\n\n    const editor = new MockTextEditor(document, options?.viewColumn);\n\n    if (options?.selection) {\n      editor.selection = new Selection(\n        options.selection.start,\n        options.selection.end\n      );\n      editor.selections = [editor.selection];\n    }\n\n    mockState.activeTextEditor = editor;\n\n    if (!mockState.visibleTextEditors.includes(editor)) {\n      mockState.visibleTextEditors.push(editor);\n    }\n\n    return editor;\n  },\n\n  async showInformationMessage(\n    message: string,\n    ...items: string[]\n  ): Promise<string | undefined> {\n    // Mock implementation - do nothing\n    return undefined;\n  },\n\n  async showWarningMessage(\n    message: string,\n    ...items: string[]\n  ): Promise<string | undefined> {\n    // Mock implementation - do nothing\n    return undefined;\n  },\n\n  async showErrorMessage(\n    message: string,\n    ...items: string[]\n  ): Promise<string | undefined> {\n    throw new Error(\n      'showErrorMessage called - should be mocked in tests if error handling is expected. Message was: ' +\n        message\n    );\n  },\n\n  async withProgress<R>(\n    options: {\n      location: ProgressLocation;\n      title?: string;\n      cancellable?: boolean;\n    },\n    task: (\n      progress: Progress<{ message?: string; increment?: number }>,\n      token: CancellationToken\n    ) => Thenable<R>\n  ): Promise<R> {\n    const tokenSource = new CancellationTokenSource();\n    const progress: Progress<{ message?: string; increment?: number }> = {\n      report: () => {\n        // No-op in mock, but can be overridden in tests\n      },\n    };\n\n    try {\n      return await task(progress, tokenSource.token);\n    } finally {\n      tokenSource.dispose();\n    }\n  },\n};\n\n// Workspace namespace\nexport const workspace = {\n  get workspaceFolders(): WorkspaceFolder[] | undefined {\n    return mockState.workspaceFolders.length > 0\n      ? mockState.workspaceFolders\n      : undefined;\n  },\n\n  get fs(): FileSystem {\n    return mockState.fileSystem;\n  },\n\n  createFileSystemWatcher(globPattern: string): FileSystemWatcher {\n    return new MockFileSystemWatcher(globPattern);\n  },\n\n  getConfiguration(section?: string): WorkspaceConfiguration {\n    if (section) {\n      // Return a scoped configuration for the specific section\n      const scopedConfig = new MockWorkspaceConfiguration();\n      // Copy relevant config values that start with the section\n      for (const [key, value] of (mockState.configuration as any)._config) {\n        if (key.startsWith(`${section}.`)) {\n          const sectionKey = key.substring(section.length + 1);\n          (scopedConfig as any)._config.set(sectionKey, value);\n        }\n      }\n      return scopedConfig;\n    }\n    return mockState.configuration;\n  },\n\n  async findFiles(\n    include: string,\n    exclude?: string,\n    maxResults?: number\n  ): Promise<Uri[]> {\n    // Simple implementation that recursively finds files\n    const workspaceFolder = mockState.workspaceFolders[0];\n\n    if (!workspaceFolder) {\n      return [];\n    }\n\n    const findFilesRecursive = async (dir: string): Promise<string[]> => {\n      const files: string[] = [];\n      try {\n        const entries = await fs.promises.readdir(dir, { withFileTypes: true });\n\n        for (const entry of entries) {\n          const fullPath = path.join(dir, entry.name);\n          const relativePath = path.relative(\n            workspaceFolder.uri.fsPath,\n            fullPath\n          );\n\n          if (entry.isDirectory()) {\n            const subFiles = await findFilesRecursive(fullPath);\n            files.push(...subFiles);\n          } else if (entry.isFile()) {\n            // Check if file matches include pattern\n            if (micromatch.isMatch(relativePath, include)) {\n              // Check if file matches exclude pattern\n              if (!exclude || !micromatch.isMatch(relativePath, exclude)) {\n                files.push(fullPath);\n              }\n            }\n          }\n        }\n      } catch (error) {\n        // Ignore errors accessing directories\n      }\n\n      return files;\n    };\n\n    try {\n      const files = await findFilesRecursive(workspaceFolder.uri.fsPath);\n\n      let result = files.map(file => createVSCodeUri(URI.file(file)));\n\n      if (maxResults && result.length > maxResults) {\n        result = result.slice(0, maxResults);\n      }\n\n      return result;\n    } catch (error) {\n      return [];\n    }\n  },\n\n  getWorkspaceFolder(uri: Uri): WorkspaceFolder | undefined {\n    const workspaceFolder = mockState.workspaceFolders.find(folder =>\n      uri.fsPath.startsWith(folder.uri.fsPath)\n    );\n    return workspaceFolder;\n  },\n\n  onWillSaveTextDocument(listener: (e: any) => void): Disposable {\n    // Mock event listener for document save events\n    return {\n      dispose: () => {\n        // No-op\n      },\n    };\n  },\n\n  onWillRenameFiles(listener: (e: any) => any): Disposable {\n    mockState.onWillRenameFilesListeners.push(listener);\n    return {\n      dispose: () => {\n        const idx = mockState.onWillRenameFilesListeners.indexOf(listener);\n        if (idx >= 0) {\n          mockState.onWillRenameFilesListeners.splice(idx, 1);\n        }\n      },\n    };\n  },\n\n  onDidRenameFiles(listener: (e: any) => any): Disposable {\n    mockState.onDidRenameFilesListeners.push(listener);\n    return {\n      dispose: () => {\n        const idx = mockState.onDidRenameFilesListeners.indexOf(listener);\n        if (idx >= 0) {\n          mockState.onDidRenameFilesListeners.splice(idx, 1);\n        }\n      },\n    };\n  },\n\n  onWillDeleteFiles(listener: (e: any) => any): Disposable {\n    mockState.onWillDeleteFilesListeners.push(listener);\n    return {\n      dispose: () => {\n        const idx = mockState.onWillDeleteFilesListeners.indexOf(listener);\n        if (idx >= 0) {\n          mockState.onWillDeleteFilesListeners.splice(idx, 1);\n        }\n      },\n    };\n  },\n\n  onDidChangeConfiguration(listener: (e: any) => void): Disposable {\n    return { dispose: () => {} };\n  },\n\n  async openTextDocument(\n    uriOrFileNameOrOptions:\n      | Uri\n      | string\n      | { language?: string; content?: string }\n  ): Promise<TextDocument> {\n    let uri: Uri;\n    let content: string | undefined;\n\n    if (typeof uriOrFileNameOrOptions === 'string') {\n      uri = createVSCodeUri(URI.file(uriOrFileNameOrOptions));\n    } else if ('scheme' in uriOrFileNameOrOptions) {\n      uri = uriOrFileNameOrOptions;\n    } else {\n      // Create untitled document\n      uri = createVSCodeUri(\n        URI.parse(`untitled:Untitled-${Date.now()}`, 'file')\n      );\n      content = uriOrFileNameOrOptions.content || '';\n    }\n\n    // Return cached instance so edits via applyEdit are reflected in test references\n    const key = uri.toString();\n    const existing = mockState.openDocuments.get(key);\n    if (existing && content === undefined) {\n      return existing;\n    }\n\n    const document = new MockTextDocument(uri, content);\n    mockState.openDocuments.set(key, document);\n    return document;\n  },\n\n  async applyEdit(edit: WorkspaceEdit): Promise<boolean> {\n    try {\n      // Collect rename operations and fire onWillRenameFiles before processing\n      const renames: { oldUri: Uri; newUri: Uri }[] = [];\n      for (const [, edits] of edit._getEdits()) {\n        for (const e of edits) {\n          if (e.type === 'rename') {\n            renames.push(e);\n          }\n        }\n      }\n      if (\n        renames.length > 0 &&\n        mockState.onWillRenameFilesListeners.length > 0\n      ) {\n        const event = { files: renames };\n        for (const listener of mockState.onWillRenameFilesListeners) {\n          const result = listener(event);\n          if (result && typeof result.then === 'function') {\n            await result;\n          }\n        }\n      }\n\n      for (const [uriString, edits] of edit._getEdits()) {\n        const uri = createVSCodeUri(URI.parse(uriString, 'file'));\n\n        // Apply text edits\n        const textEdits = edits.filter(e => e instanceof TextEdit || e.range);\n        if (textEdits.length > 0) {\n          const document = await workspace.openTextDocument(uri);\n          if (document instanceof MockTextDocument) {\n            let content = document.getText();\n            textEdits.sort((a, b) =>\n              Position.compareTo(b.range.start, a.range.start)\n            );\n            for (const e of textEdits) {\n              content = FoamTextEdit.apply(content, {\n                newText: e.newText,\n                range: e.range,\n              });\n            }\n            document._updateContent(content);\n          }\n        }\n\n        // Handle file renames\n        const otherEdits = edits.filter(\n          e => !(e instanceof TextEdit || e.range)\n        );\n        for (const e of otherEdits) {\n          if (e.type === 'rename') {\n            // Build the list of (oldUri, newUri) pairs to fire events for.\n            // For directory renames, expand to individual files; for files, it's a single pair.\n            let isDirectory = false;\n            try {\n              const stat = await fs.promises.stat(e.oldUri.fsPath);\n              isDirectory = stat.isDirectory();\n            } catch {\n              // Not a directory or doesn't exist\n            }\n\n            const filePairs: { oldFileUri: Uri; newFileUri: Uri }[] = [];\n            if (isDirectory) {\n              const oldFiles = await collectFilesRecursively(e.oldUri.fsPath);\n              for (const oldFilePath of oldFiles) {\n                const relPath = path.relative(e.oldUri.fsPath, oldFilePath);\n                const newFilePath = path.join(e.newUri.fsPath, relPath);\n                filePairs.push({\n                  oldFileUri: createVSCodeUri(URI.file(oldFilePath)),\n                  newFileUri: createVSCodeUri(URI.file(newFilePath)),\n                });\n              }\n            } else {\n              filePairs.push({ oldFileUri: e.oldUri, newFileUri: e.newUri });\n            }\n\n            await fs.promises.rename(e.oldUri.fsPath, e.newUri.fsPath);\n            mockState.openDocuments.delete(e.oldUri.toString());\n            for (const { oldFileUri, newFileUri } of filePairs) {\n              for (const watcher of mockState.fileWatchers) {\n                watcher._fireDelete(oldFileUri);\n                watcher._fireCreate(newFileUri);\n              }\n            }\n          }\n        }\n      }\n\n      // Fire onDidRenameFiles after all renames are complete\n      if (\n        renames.length > 0 &&\n        mockState.onDidRenameFilesListeners.length > 0\n      ) {\n        const event = { files: renames };\n        for (const listener of mockState.onDidRenameFilesListeners) {\n          const result = listener(event);\n          if (result && typeof result.then === 'function') {\n            await result;\n          }\n        }\n      }\n\n      return true;\n    } catch (e) {\n      Logger.error('vscode-mock: Failed to apply edit', e);\n      return false;\n    }\n  },\n\n  asRelativePath(\n    pathOrUri: string | Uri,\n    includeWorkspaceFolder?: boolean\n  ): string {\n    const workspaceFolder = mockState.workspaceFolders[0];\n    if (!workspaceFolder) {\n      return typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath;\n    }\n\n    const fsPath = typeof pathOrUri === 'string' ? pathOrUri : pathOrUri.fsPath;\n    const relativePath = path.relative(workspaceFolder.uri.fsPath, fsPath);\n\n    if (includeWorkspaceFolder) {\n      return `${workspaceFolder.name}/${relativePath}`;\n    }\n\n    return relativePath;\n  },\n\n  get isTrusted(): boolean {\n    // Mock workspace as trusted for testing\n    return true;\n  },\n};\n\n// Commands namespace\nexport const commands = {\n  registerCommand(\n    command: string,\n    callback: (...args: any[]) => any\n  ): { dispose(): void } {\n    mockState.commands.set(command, callback);\n    return {\n      dispose() {\n        mockState.commands.delete(command);\n      },\n    };\n  },\n\n  async executeCommand<T = unknown>(\n    command: string,\n    ...args: any[]\n  ): Promise<T> {\n    // Auto-initialize Foam commands if this is a foam-vscode command\n    if (command.startsWith('foam-vscode.')) {\n      await initializeFoamCommands(await TestFoam.getInstance());\n    }\n\n    const handler = mockState.commands.get(command);\n    if (!handler) {\n      throw new Error(`Command '${command}' not found`);\n    }\n\n    return handler(...args);\n  },\n};\n\n// Languages namespace\nexport const languages = {\n  registerCodeLensProvider(selector: any, provider: any): Disposable {\n    // Mock code lens provider registration\n    return {\n      dispose: () => {\n        // No-op\n      },\n    };\n  },\n\n  registerWorkspaceSymbolProvider(provider: any): Disposable {\n    // Mock workspace symbol provider registration\n    return {\n      dispose: () => {\n        // No-op\n      },\n    };\n  },\n};\n\n// Extensions namespace\nexport const extensions = {\n  getExtension<T = any>(extensionId: string): Extension<T> | undefined {\n    if (extensionId === 'foam.foam-vscode') {\n      return new MockExtension<any>(\n        extensionId,\n        {\n          get foam() {\n            return TestFoam.getInstance();\n          },\n        },\n        true\n      ) as Extension<T>;\n    }\n    return undefined;\n  },\n\n  get all(): Extension<any>[] {\n    const foamExtension = new MockExtension<any>(\n      'foam.foam-vscode',\n      {\n        get foam() {\n          return TestFoam.getInstance();\n        },\n      },\n      true\n    );\n    return [foamExtension];\n  },\n};\n\n// Env namespace\nexport const env = {\n  __mockClipboard: '',\n  clipboard: {\n    async writeText(value: string): Promise<void> {\n      env.__mockClipboard = value;\n    },\n\n    async readText(): Promise<string> {\n      return env.__mockClipboard || '';\n    },\n  },\n\n  // Other common env properties\n  appName: 'Visual Studio Code',\n  appRoot: '/mock/vscode',\n  language: 'en',\n  sessionId: 'mock-session',\n  machineId: 'mock-machine',\n};\n\n// ===== Initialization Helper =====\n\nexport function initializeWorkspace(workspaceRoot: string): void {\n  const uri = createVSCodeUri(URI.file(workspaceRoot));\n  const folder: WorkspaceFolder = {\n    uri,\n    name: path.basename(workspaceRoot),\n    index: 0,\n  };\n\n  mockState.workspaceFolders = [folder];\n}\n\n// ===== Utility Functions =====\n\n// Clean up state for tests\nexport function resetMockState(): void {\n  // Clean up existing Foam instance\n  TestFoam.dispose();\n  foamCommandsInitialized = false;\n  mockState.activeTextEditor = undefined;\n  mockState.visibleTextEditors = [];\n  mockState.workspaceFolders = [];\n  mockState.commands.clear();\n  mockState.onWillRenameFilesListeners = [];\n  mockState.onDidRenameFilesListeners = [];\n  mockState.onWillDeleteFilesListeners = [];\n  mockState.openDocuments.clear();\n  mockState.configuration = new MockWorkspaceConfiguration();\n\n  // Create a default workspace folder for tests\n  const defaultWorkspaceRoot = path.join(\n    os.tmpdir(),\n    'foam-mock-workspace-' + randomString(3)\n  );\n  fs.mkdirSync(defaultWorkspaceRoot, { recursive: true });\n\n  initializeWorkspace(defaultWorkspaceRoot);\n\n  // Register built-in VS Code commands\n  commands.registerCommand('workbench.action.closeAllEditors', () => {\n    mockState.activeTextEditor = undefined;\n    mockState.visibleTextEditors = [];\n    mockState.openDocuments.clear();\n    return Promise.resolve();\n  });\n\n  commands.registerCommand('vscode.open', async uri => {\n    // Mock opening a file - just show it in editor\n    return window.showTextDocument(uri);\n  });\n\n  commands.registerCommand('setContext', (key: string, value: any) => {\n    // Mock command for setting VS Code context\n    return Promise.resolve();\n  });\n}\n\n// Initialize the mock state when the module is loaded\nresetMockState();\n\n// ===== Force Cleanup for Test Files =====\n\nexport async function forceCleanup(): Promise<void> {\n  // Clean up existing Foam instance\n  TestFoam.dispose();\n\n  // Clear all registered commands\n  mockState.commands.clear();\n\n  // Clear all event listeners by resetting emitters\n  mockState.activeTextEditor = undefined;\n  mockState.visibleTextEditors = [];\n\n  // Close any open file handles by clearing the file system\n  mockState.fileSystem = new MockFileSystem();\n\n  // Clear configuration\n  mockState.configuration = new MockWorkspaceConfiguration();\n\n  // Force garbage collection\n  if (global.gc) {\n    global.gc();\n  }\n\n  // Wait for any pending file system operations to complete\n  await new Promise(resolve => setTimeout(resolve, 10));\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/types.d.ts",
    "content": "import { ExtensionContext } from 'vscode';\nimport { Foam } from './core/model/foam';\n\nexport type FoamFeature = (\n  context: ExtensionContext,\n  foamPromise: Promise<Foam>\n) => Promise<any> | void;\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/commands.ts",
    "content": "import { Uri } from 'vscode';\nimport { merge } from 'lodash';\n\nexport interface CommandDescriptor<T> {\n  name: string;\n  params: T;\n}\n\nexport function describeCommand<T>(\n  base: CommandDescriptor<T>,\n  ...extra: Partial<T>[]\n) {\n  return merge(base, ...extra.map(e => ({ params: e })));\n}\n\nexport function commandAsURI<T>(command: CommandDescriptor<T>) {\n  return Uri.parse(`command:${command.name}`, null).with({\n    query: encodeURIComponent(JSON.stringify(command.params)),\n  });\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/globExpand.test.ts",
    "content": "import { expandAlternateGroups } from './globExpand';\n\ndescribe('testExpandAlternateGroupsForGlobs', () => {\n  it('expands simple alternates', () => {\n    expect(expandAlternateGroups('ignoredFile{1,2}.txt').sort()).toEqual(\n      ['ignoredFile1.txt', 'ignoredFile2.txt'].sort()\n    );\n  });\n\n  it('returns pattern unchanged if no braces', () => {\n    expect(expandAlternateGroups('**/.git')).toEqual(['**/.git']);\n  });\n\n  it('multiple alternates recursively', () => {\n    expect(expandAlternateGroups('foo{a,b}bar{1,2}.txt').sort()).toEqual(\n      ['fooabar1.txt', 'fooabar2.txt', 'foobbar1.txt', 'foobbar2.txt'].sort()\n    );\n  });\n\n  it('handles patterns with no alternates etc.', () => {\n    expect(expandAlternateGroups('plainfile.txt')).toEqual(['plainfile.txt']);\n  });\n\n  it('handles empty alternates', () => {\n    expect(expandAlternateGroups('file{}.txt')).toEqual(['file{}.txt']);\n  });\n\n  it('expands nested alternates', () => {\n    expect(expandAlternateGroups('foo{{a,b},c}.txt').sort()).toEqual(\n      ['fooa.txt', 'fooc.txt', 'foob.txt', 'fooc.txt'].sort()\n    );\n  });\n\n  it('handles highly nested braces as literals', () => {\n    expect(expandAlternateGroups('foo{{a,{b,c}},d}.txt').sort()).toEqual(\n      [\n        'fooa.txt',\n        'food.txt',\n        'foob.txt',\n        'food.txt',\n        'fooa.txt',\n        'food.txt',\n        'fooc.txt',\n        'food.txt',\n      ].sort()\n    );\n  });\n\n  it('expands alternates at the start of the pattern', () => {\n    expect(expandAlternateGroups('{a,b}file.txt').sort()).toEqual(\n      ['afile.txt', 'bfile.txt'].sort()\n    );\n  });\n\n  it('expands alternates at the end of the pattern', () => {\n    expect(expandAlternateGroups('file.{js,ts}').sort()).toEqual(\n      ['file.js', 'file.ts'].sort()\n    );\n  });\n\n  it('expands alternates with more than two options', () => {\n    expect(expandAlternateGroups('file{1,2,3}.txt').sort()).toEqual(\n      ['file1.txt', 'file2.txt', 'file3.txt'].sort()\n    );\n  });\n\n  it('handles patterns with multiple sets of braces (not nested)', () => {\n    expect(expandAlternateGroups('foo{a,b}bar{x,y}.txt').sort()).toEqual(\n      ['fooabarx.txt', 'fooabary.txt', 'foobbarx.txt', 'foobbary.txt'].sort()\n    );\n  });\n\n  it('handles spaces outside the groups by treating them as part of the pattern', () => {\n    expect(expandAlternateGroups('foo {a,b} bar.txt').sort()).toEqual(\n      ['foo a bar.txt', 'foo b bar.txt'].sort()\n    );\n  });\n\n  it('handles spaces inside the groups by treating them as part of the alternates', () => {\n    expect(expandAlternateGroups('foo{ a, b }bar.txt').sort()).toEqual(\n      ['foo abar.txt', 'foo b bar.txt'].sort()\n    );\n  });\n\n  it('handles multiple patterns with nesting and spaces', () => {\n    expect(\n      expandAlternateGroups(\n        'foo{a,b}bar{1,2}with{e,{ f ,g },{ h,{i, j,{k , l, m}}}}.txt'\n      ).sort()\n    ).toEqual(\n      [\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with f .txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with h.txt',\n        'fooabar1with j.txt',\n        'fooabar1with j.txt',\n        'fooabar1with j.txt',\n        'fooabar1with j.txt',\n        'fooabar1with j.txt',\n        'fooabar1with j.txt',\n        'fooabar1with l.txt',\n        'fooabar1with l.txt',\n        'fooabar1with m.txt',\n        'fooabar1with m.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withe.txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withg .txt',\n        'fooabar1withi.txt',\n        'fooabar1withi.txt',\n        'fooabar1withi.txt',\n        'fooabar1withi.txt',\n        'fooabar1withi.txt',\n        'fooabar1withi.txt',\n        'fooabar1withk .txt',\n        'fooabar1withk .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with f .txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with h.txt',\n        'fooabar2with j.txt',\n        'fooabar2with j.txt',\n        'fooabar2with j.txt',\n        'fooabar2with j.txt',\n        'fooabar2with j.txt',\n        'fooabar2with j.txt',\n        'fooabar2with l.txt',\n        'fooabar2with l.txt',\n        'fooabar2with m.txt',\n        'fooabar2with m.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withe.txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withg .txt',\n        'fooabar2withi.txt',\n        'fooabar2withi.txt',\n        'fooabar2withi.txt',\n        'fooabar2withi.txt',\n        'fooabar2withi.txt',\n        'fooabar2withi.txt',\n        'fooabar2withk .txt',\n        'fooabar2withk .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with f .txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with h.txt',\n        'foobbar1with j.txt',\n        'foobbar1with j.txt',\n        'foobbar1with j.txt',\n        'foobbar1with j.txt',\n        'foobbar1with j.txt',\n        'foobbar1with j.txt',\n        'foobbar1with l.txt',\n        'foobbar1with l.txt',\n        'foobbar1with m.txt',\n        'foobbar1with m.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withe.txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withg .txt',\n        'foobbar1withi.txt',\n        'foobbar1withi.txt',\n        'foobbar1withi.txt',\n        'foobbar1withi.txt',\n        'foobbar1withi.txt',\n        'foobbar1withi.txt',\n        'foobbar1withk .txt',\n        'foobbar1withk .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with f .txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with h.txt',\n        'foobbar2with j.txt',\n        'foobbar2with j.txt',\n        'foobbar2with j.txt',\n        'foobbar2with j.txt',\n        'foobbar2with j.txt',\n        'foobbar2with j.txt',\n        'foobbar2with l.txt',\n        'foobbar2with l.txt',\n        'foobbar2with m.txt',\n        'foobbar2with m.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withe.txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withg .txt',\n        'foobbar2withi.txt',\n        'foobbar2withi.txt',\n        'foobbar2withi.txt',\n        'foobbar2withi.txt',\n        'foobbar2withi.txt',\n        'foobbar2withi.txt',\n        'foobbar2withk .txt',\n        'foobbar2withk .txt',\n      ].sort()\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/globExpand.ts",
    "content": "import { GlobPattern } from 'vscode';\n/**\n * Expands simple brace alternates in a glob pattern, e.g.\n * 'ignoredFile{1,2}.txt' => ['ignoredFile1.txt', 'ignoredFile2.txt']\n */\nexport function expandAlternateGroups(pattern: string): GlobPattern[] {\n  const match = pattern.match(/^(.*)\\{([^{}]+)\\}(.*)$/);\n  if (!match) {\n    return [pattern];\n  }\n  const [_, prefix, alternates, suffix] = match;\n  return alternates\n    .split(',')\n    .flatMap(alt => expandAlternateGroups(`${prefix}${alt}${suffix}`));\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/template-frontmatter-parser.test.ts",
    "content": "import {\n  extractFoamTemplateFrontmatterMetadata,\n  removeFoamMetadata,\n} from './template-frontmatter-parser';\n\ndescribe('extractFoamTemplateFrontmatterMetadata', () => {\n  test('Returns an empty object if there is not frontmatter', () => {\n    const input = `# $FOAM_TITLE`;\n    const expectedMetadata = new Map<string, string>();\n    const expected = [expectedMetadata, input];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns an empty object if `foam_template` is not used', () => {\n    const input = `---\nfoo: bar\n---\n\n# $FOAM_TITLE\n`;\n\n    const expectedMetadata = new Map<string, string>();\n    const expected = [expectedMetadata, input];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns an empty object if foam_template is not a YAML mapping', () => {\n    const input = `---json\n{\n  \"foo\": \"bar\",\n  \"foam_template\": 4\n}\n---\n\n# $FOAM_TITLE\n`;\n\n    const expectedMetadata = new Map<string, string>();\n    const expected = [expectedMetadata, input];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns an empty object if frontmatter is not YAML', () => {\n    const input = `---json\n{\n  \"foo\": \"bar\",\n  \"foam_template\": {\n    \"filepath\": \"journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md\"\n  }\n}\n---\n\n# $FOAM_TITLE\n`;\n\n    const expectedMetadata = new Map<string, string>();\n    const expected = [expectedMetadata, input];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns the `foam_template` metadata when it is used in its own frontmatter block', () => {\n    const input = `---\nfoam_template:\n  name: My Note Template\n  description: This is my note template\n  filepath: journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md\n---\n\n# $FOAM_TITLE\n`;\n\n    const output = `\n# $FOAM_TITLE\n`;\n\n    const expectedMetadata = new Map<string, string>();\n    expectedMetadata.set('name', 'My Note Template');\n    expectedMetadata.set('description', 'This is my note template');\n    expectedMetadata.set(\n      'filepath',\n      'journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md'\n    );\n    const expected = [expectedMetadata, output];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns the `foam_template` metadata when it is used in its own frontmatter block (and there is another frontmatter block after)', () => {\n    const input = `---\nfoam_template:\n  filepath: journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md\n  description: This is my note template\n  name: My Note Template\n---\n\n---\nfoo: bar\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE\n`;\n\n    const output = `---\nfoo: bar\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE\n`;\n\n    const expectedMetadata = new Map<string, string>();\n    expectedMetadata.set('name', 'My Note Template');\n    expectedMetadata.set('description', 'This is my note template');\n    expectedMetadata.set(\n      'filepath',\n      'journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md'\n    );\n    const expected = [expectedMetadata, output];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Returns the `foam_template` metadata when it is used in a shared frontmatter block', () => {\n    const input = `---\nfoo: bar\nfoam_template:\n  name: My Note Template\n  filepath: journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md\n  description: This is my note template\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE`;\n\n    const output = `---\nfoo: bar\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE`;\n\n    const expectedMetadata = new Map<string, string>();\n    expectedMetadata.set('name', 'My Note Template');\n    expectedMetadata.set('description', 'This is my note template');\n    expectedMetadata.set(\n      'filepath',\n      'journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md'\n    );\n    const expected = [expectedMetadata, output];\n    const result = extractFoamTemplateFrontmatterMetadata(input);\n    expect(result).toEqual(expected);\n  });\n});\n\ndescribe('removeFoamMetadata', () => {\n  test('Removes Foam specific frontmatter without messing up non-Foam frontmatter', () => {\n    const input = `---\nfoo: bar\nfoam_template: &foam_template # A YAML comment\n  description: This is my note template\n  filepath: journal/$CURRENT_YEAR-$CURRENT_MONTH-$CURRENT_DATE_$FOAM_TITLE.md # A YAML comment\n  name: My Note Template\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE`;\n\n    const expected = `---\nfoo: bar\n# A YAML comment\nmetadata: &info\n  title: The Gentlemen\n  year: 2019\nmore_metadata: *info\n---\n\n# $FOAM_TITLE`;\n\n    const result = removeFoamMetadata(input);\n    expect(result).toEqual(expected);\n  });\n\n  test('Removes Foam specific frontmatter when file uses CRLF line endings', () => {\n    const input =\n      '---\\r\\nfoo: bar\\r\\nfoam_template:\\r\\n  description: This is my note template\\r\\n  filepath: journal/note.md\\r\\n  name: My Note Template\\r\\n---\\r\\n\\r\\n# $FOAM_TITLE';\n\n    const expected = '---\\r\\nfoo: bar\\r\\n---\\r\\n\\r\\n# $FOAM_TITLE';\n\n    const result = removeFoamMetadata(input);\n    expect(result).toEqual(expected);\n  });\n});\n\ndescribe('extractFoamTemplateFrontmatterMetadata with CRLF', () => {\n  test('Removes foam_template block from mixed frontmatter with CRLF line endings', () => {\n    const input =\n      '---\\r\\ntype: daily-note\\r\\nfoam_template:\\r\\n  filepath: /daily-notes/note.md\\r\\n  name: Daily Note\\r\\n  description: Custom daily note template\\r\\n---\\r\\n\\r\\n# Content\\r\\n';\n\n    const expectedOutput =\n      '---\\r\\ntype: daily-note\\r\\n---\\r\\n\\r\\n# Content\\r\\n';\n\n    const expectedMetadata = new Map<string, string>();\n    expectedMetadata.set('filepath', '/daily-notes/note.md');\n    expectedMetadata.set('name', 'Daily Note');\n    expectedMetadata.set('description', 'Custom daily note template');\n\n    const [metadata, output] = extractFoamTemplateFrontmatterMetadata(input);\n    expect(metadata).toEqual(expectedMetadata);\n    expect(output).toEqual(expectedOutput);\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/template-frontmatter-parser.ts",
    "content": "import matter from 'gray-matter';\n\nexport function extractFoamTemplateFrontmatterMetadata(\n  contents: string\n): [Map<string, string>, string] {\n  // Need to pass in empty options object, in order to bust a cache\n  // See https://github.com/jonschlinkert/gray-matter/issues/124\n  const parsed = matter(contents, {});\n  let metadata = new Map<string, string>();\n\n  if (parsed.language !== 'yaml') {\n    // We might allow this in the future, once it has been tested adequately.\n    // But for now we'll be safe and prevent people from using anything else.\n    return [metadata, contents];\n  }\n\n  const frontmatter = parsed.data;\n  const frontmatterKeys = Object.keys(frontmatter);\n  const foamMetadata = frontmatter['foam_template'];\n\n  if (typeof foamMetadata !== 'object') {\n    return [metadata, contents];\n  }\n\n  const containsFoam = foamMetadata !== undefined;\n  const onlyFoam = containsFoam && frontmatterKeys.length === 1;\n  metadata = new Map<string, string>(\n    Object.entries((foamMetadata as object) || {})\n  );\n\n  let newContents = contents;\n  if (containsFoam) {\n    if (onlyFoam) {\n      // We'll remove the entire frontmatter block\n      newContents = parsed.content;\n\n      // If there is another frontmatter block, we need to remove\n      // the leading space left behind.\n      const anotherFrontmatter = matter(newContents.trimStart()).matter !== '';\n      if (anotherFrontmatter) {\n        newContents = newContents.trimStart();\n      }\n    } else {\n      // We'll remove only the Foam bits\n      newContents = removeFoamMetadata(contents);\n    }\n  }\n\n  return [metadata, newContents];\n}\n\nexport function removeFoamMetadata(contents: string) {\n  return contents.replace(\n    /^[ \\t]*foam_template:.*?\\r?\\n(?:[ \\t]*(?:filepath|name|description):.*\\r?\\n)+/gm,\n    ''\n  );\n}\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/vsc-utils.spec.ts",
    "content": "import { Uri } from 'vscode';\nimport { fromVsCodeUri, toVsCodeUri } from './vsc-utils';\n\ndescribe('URI conversion', () => {\n  it('converts between Foam and VS Code URI', () => {\n    const vsUnixUri = Uri.file('/this/is/a/path');\n    const fUnixUri = fromVsCodeUri(vsUnixUri);\n    expect(toVsCodeUri(fUnixUri)).toEqual(expect.objectContaining(fUnixUri));\n\n    const vsWinUpperDriveUri = Uri.file('C:\\\\this\\\\is\\\\a\\\\path');\n    const fWinUpperUri = fromVsCodeUri(vsWinUpperDriveUri);\n    expect(toVsCodeUri(fWinUpperUri)).toEqual(\n      expect.objectContaining(fWinUpperUri)\n    );\n\n    const vsWinLowerUri = Uri.file('c:\\\\this\\\\is\\\\a\\\\path');\n    const fWinLowerUri = fromVsCodeUri(vsWinLowerUri);\n    expect(toVsCodeUri(fWinLowerUri)).toEqual(\n      expect.objectContaining({\n        ...fWinLowerUri,\n        path: fWinUpperUri.path, // path is normalized to upper case\n      })\n    );\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/src/utils/vsc-utils.ts",
    "content": "import {\n  Memento,\n  Position,\n  Range,\n  Uri,\n  TextEdit,\n  WorkspaceEdit,\n  commands,\n} from 'vscode';\nimport { Position as FoamPosition } from '../core/model/position';\nimport { Range as FoamRange } from '../core/model/range';\nimport { URI as FoamURI } from '../core/model/uri';\nimport {\n  TextEdit as FoamTextEdit,\n  WorkspaceTextEdit,\n} from '../core/services/text-edit';\nimport { FoamWorkspace } from '../core/model/workspace';\nimport { Logger } from '../core/utils/log';\n\nexport const toVsCodePosition = (p: FoamPosition): Position =>\n  new Position(p.line, p.character);\n\nexport const toVsCodeRange = (r: FoamRange): Range =>\n  new Range(r.start.line, r.start.character, r.end.line, r.end.character);\n\nexport const toVsCodeUri = (u: FoamURI): Uri => Uri.from(u);\n\nexport const fromVsCodeUri = (u: Uri): FoamURI =>\n  FoamURI.parse(u.toString(), null);\n\nexport const toVsCodeTextEdit = (edit: FoamTextEdit): TextEdit =>\n  new TextEdit(toVsCodeRange(edit.range), edit.newText);\n\n/**\n * Convert WorkspaceTextEdit array to VS Code WorkspaceEdit.\n *\n * @param workspaceTextEdits Array of workspace text edits to convert\n * @param workspace Foam workspace for URI resolution\n * @returns VS Code WorkspaceEdit ready for application\n */\nexport const toVsCodeWorkspaceEdit = (\n  workspaceTextEdits: WorkspaceTextEdit[],\n  workspace: FoamWorkspace\n): WorkspaceEdit => {\n  const workspaceEdit = new WorkspaceEdit();\n\n  // Group edits by URI\n  const editsByUri = new Map<string, { uri: Uri; edits: TextEdit[] }>();\n\n  for (const workspaceTextEdit of workspaceTextEdits) {\n    const resource = workspace.get(workspaceTextEdit.uri);\n    if (!resource) {\n      Logger.warn(\n        `Could not resolve resource: ${workspaceTextEdit.uri.toString()}`\n      );\n      continue;\n    }\n\n    const vscodeUri = toVsCodeUri(resource.uri);\n    const uriKey = resource.uri.toString();\n    const existingEntry = editsByUri.get(uriKey) || {\n      uri: vscodeUri,\n      edits: [],\n    };\n\n    const vscodeEdit = new TextEdit(\n      toVsCodeRange(workspaceTextEdit.edit.range),\n      workspaceTextEdit.edit.newText\n    );\n\n    existingEntry.edits.push(vscodeEdit);\n    editsByUri.set(uriKey, existingEntry);\n  }\n\n  // Apply grouped edits to workspace\n  for (const { uri, edits } of editsByUri.values()) {\n    workspaceEdit.set(uri, edits);\n  }\n\n  return workspaceEdit;\n};\n\n/**\n * A class that wraps context value, syncs it via setContext, and provides a typed interface to it.\n */\nexport class ContextMemento<T> {\n  constructor(\n    private data: Memento,\n    private key: string,\n    defaultValue: T,\n    resetToDefault: boolean = false\n  ) {\n    resetToDefault && this.data.update(this.key, defaultValue);\n    const value = data.get(key) ?? defaultValue;\n    commands.executeCommand('setContext', this.key, value);\n  }\n  public get(): T {\n    return this.data.get(this.key);\n  }\n  public async update(value: T): Promise<void> {\n    this.data.update(this.key, value);\n    await commands.executeCommand('setContext', this.key, value);\n  }\n}\n\n/**\n * Implementation of the Memento interface that uses a Map as backend\n */\nexport class MapBasedMemento implements Memento {\n  get<T>(key: unknown, defaultValue?: unknown | T): T | T {\n    return (this.map.get(key as string) as T) || (defaultValue as T);\n  }\n  private map: Map<string, string> = new Map();\n  keys(): readonly string[] {\n    return Array.from(this.map.keys());\n  }\n  update(key: string, value: any): Promise<void> {\n    this.map.set(key, value);\n    return Promise.resolve();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/static/preview/block-anchor-scroll.js",
    "content": "/**\n * Handles in-preview scroll-to-fragment for Foam block anchors.\n *\n * Block anchor elements have ids prefixed with '__' (e.g. id=\"__myblock\").\n *\n * Two cases:\n *\n * 1. Same-document links ([[#^blockid]] → href=\"#__blockid\"):\n *    VS Code's click handler returns early for '#'-prefixed hrefs, relying on\n *    browser-native hash navigation. In VS Code's webview this doesn't reliably\n *    scroll to arbitrary elements (only headings work via the sync mechanism).\n *    We intercept the click ourselves and call scrollIntoView directly.\n *\n * 2. Cross-document links ([[note#^blockid]] → href=\"/note.md#__blockid\"):\n *    VS Code opens the new note's preview. We stash the fragment in\n *    sessionStorage before the navigation happens, and read it when the target\n *    note's preview script runs (either on initial load or via the\n *    vscode.markdown.updateContent event for in-place re-renders).\n */\n\nconst BLOCK_ANCHOR_PREFIX = '__';\nconst FRAGMENT_STORAGE_KEY = 'foam-block-anchor-fragment';\n\nfunction scrollToBlockAnchor(fragment) {\n  if (!fragment || !fragment.startsWith(BLOCK_ANCHOR_PREFIX)) {\n    return false;\n  }\n  const el = document.getElementById(fragment);\n  if (el) {\n    el.scrollIntoView();\n    return true;\n  }\n  return false;\n}\n\nfunction getFragmentFromSettings() {\n  try {\n    const dataEl = document.getElementById('vscode-markdown-preview-data');\n    if (!dataEl) {\n      return null;\n    }\n    const settings = JSON.parse(dataEl.getAttribute('data-settings') ?? '{}');\n    const state = JSON.parse(dataEl.getAttribute('data-state') ?? '{}');\n    return settings.fragment ?? state.fragment ?? null;\n  } catch {\n    return null;\n  }\n}\n\nfunction getPendingFragment() {\n  try {\n    const f = sessionStorage.getItem(FRAGMENT_STORAGE_KEY);\n    if (f) {\n      sessionStorage.removeItem(FRAGMENT_STORAGE_KEY);\n    }\n    return f;\n  } catch {\n    return null;\n  }\n}\n\nfunction tryScrollFromContext() {\n  scrollToBlockAnchor(getFragmentFromSettings() ?? getPendingFragment());\n}\n\n// Case 1 & 2: intercept all block anchor link clicks (window capture phase runs\n// before VS Code's document-level handler).\nwindow.addEventListener(\n  'click',\n  e => {\n    const a = e.target.closest('a[href]');\n    if (!a) {\n      return;\n    }\n    const href = a.getAttribute('href');\n    if (!href) {\n      return;\n    }\n\n    const hashIndex = href.lastIndexOf('#');\n    if (hashIndex === -1) {\n      return;\n    }\n\n    const fragment = href.slice(hashIndex + 1); // fragment without '#'\n    if (!fragment.startsWith(BLOCK_ANCHOR_PREFIX)) {\n      return;\n    }\n\n    const pathPart = href.slice(0, hashIndex);\n\n    if (!pathPart) {\n      // Same-document link: scroll directly and suppress default navigation.\n      if (scrollToBlockAnchor(fragment)) {\n        e.preventDefault();\n        e.stopPropagation();\n      }\n    } else {\n      // Cross-document link: stash the fragment so the target note's preview\n      // script can pick it up on load or on the updateContent event.\n      try {\n        sessionStorage.setItem(FRAGMENT_STORAGE_KEY, fragment);\n      } catch {\n        // sessionStorage unavailable — silently ignore.\n      }\n    }\n  },\n  true\n);\n\n// On (re)load: scroll to any pending block anchor fragment. markdown.previewScripts\n// execute after VS Code has appended the rendered content, so the element is in DOM.\ntryScrollFromContext();\n\n// Also handle in-place re-renders triggered by live editing or cross-document\n// navigation that reuses the same webview panel.\nwindow.addEventListener('vscode.markdown.updateContent', tryScrollFromContext);\n"
  },
  {
    "path": "packages/foam-vscode/static/preview/style.css",
    "content": ".foam-placeholder-link {\n  color: var(--vscode-editorWarning-foreground);\n  cursor: default;\n}\n\n.foam-note-link,\n.foam-attachment-link {\n  color: var(--vscode-textLink-foreground);\n}\n\n.foam-tag {\n  color: var(--vscode-editorLineNumber-foreground);\n}\n\n.foam-cyclic-link-warning {\n  border: 1px solid var(--vscode-editorWarning-foreground);\n  background-color: var(--vscode-editorError-background);\n  color: var(--vscode-editorError-foreground);\n  padding: 0.5em;\n}\n\n.foam-cyclic-link-warning__stack {\n  color: var(--vscode-editorWarning-foreground);\n}\n\n.foam-embed-not-supported-warning {\n  border: 1px solid var(--vscode-editorWarning-foreground);\n  background-color: var(--vscode-editorError-background);\n  color: var(--vscode-editorWarning-foreground);\n  padding: 0.5em;\n}\n\n.embed-container-note {\n  padding: 0.5em;\n  margin: 1.5em 0;\n  border: 1px solid var(--vscode-editorLineNumber-foreground);\n}\n\n.embed-container-attachment {\n  padding: 0.25em;\n  margin: 1.5em 0;\n  text-align: center;\n  border: 1px solid var(--vscode-editorLineNumber-foreground);\n}\n\n.foam-query-warning {\n  border-left: 3px solid var(--vscode-editorWarning-foreground);\n  color: var(--vscode-editorWarning-foreground);\n  padding: 0.25em 0.75em;\n  margin: 0.5em 0;\n  font-size: 0.9em;\n}\n\n.foam-query-warning p {\n  margin: 0.2em 0;\n}\n\n.foam-query-error {\n  border-left: 3px solid var(--vscode-editorError-foreground);\n  color: var(--vscode-editorError-foreground);\n  padding: 0.25em 0.75em;\n  margin: 0.5em 0;\n  font-size: 0.9em;\n}\n\n.foam-query-empty {\n  color: var(--vscode-descriptionForeground);\n  font-style: italic;\n}\n\n.foam-query-placeholder {\n  border: 1px dashed var(--vscode-editorLineNumber-foreground);\n  background-color: var(--vscode-textBlockQuote-background);\n  color: var(--vscode-foreground);\n  padding: 0.75em 1em;\n  margin: 1em 0;\n  opacity: 0.8;\n}\n\n.foam-query-placeholder pre {\n  background-color: var(--vscode-textPreformat-background);\n  padding: 0.4em 0.6em;\n  margin: 0.5em 0;\n}\n\n.embed-container-image {\n  margin: auto;\n  padding: 0.25em;\n  text-align: center;\n}\n"
  },
  {
    "path": "packages/foam-vscode/syntaxes/injection.json",
    "content": "{\n  \"scopeName\": \"foam.wikilink.injection\",\n  \"injectionSelector\": \"L:meta.paragraph.markdown, L:markup.heading.markdown, L:markup.list.unnumbered.markdown\",\n  \"patterns\": [\n    {\n      \"name\": \"meta.link.wikilink.markdown.foam\",\n      \"begin\": \"\\\\[\\\\[\",\n      \"end\": \"\\\\]\\\\]\",\n      \"beginCaptures\": {\n        \"0\": {\n          \"name\": \"punctuation.definition.metadata.markdown.foam\"\n        }\n      },\n      \"endCaptures\": {\n        \"0\": {\n          \"name\": \"punctuation.definition.metadata.markdown.foam\"\n        }\n      },\n      \"patterns\": [\n        {\n          \"comment\": \"Wikilink with alias: [[target|alias]]\",\n          \"match\": \"([^|\\\\]]+)(\\\\|)([^\\\\]]+)\",\n          \"captures\": {\n            \"1\": {\n              \"name\": \"comment.line.wikilink.target.markdown.foam\"\n            },\n            \"2\": {\n              \"name\": \"punctuation.definition.metadata.markdown.foam\"\n            },\n            \"3\": {\n              \"name\": \"string.other.link.title.markdown.foam\"\n            }\n          }\n        },\n        {\n          \"comment\": \"Wikilink without alias: [[target]]\",\n          \"match\": \"[^|\\\\]]+\",\n          \"name\": \"string.other.link.title.markdown.foam\"\n        }\n      ]\n    }\n  ]\n}\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__migration__/Roam Document.md",
    "content": "# Roam Document\n\n[[Second Roam Document]]\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__migration__/Second Roam Document.md",
    "content": "# Second Roam Document\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/Note being referred as angel.md",
    "content": "# Note being referred as angel\n\nThis is just a link target for now.\n\nWe can use it for other things later if needed.\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/angel-reference.md",
    "content": "# Angel reference\n\n[[Note being referred as angel]]\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/file-with-different-link-formats.md",
    "content": "# File with different link formats\n\nmarkdown link [home page](https://foambubble.github.io/)\n\nwikilink to file [[first-document]].\n\nmarkdown format link to local [file](first-document.md)\n\nembedded wikilink to file ![[second-document]].\n\nwikilink to placeholder [[non-exist-file]]\n\nin-note anchor [[file-with-different-link-formats#one section]]\n\nalias to anchor [[file-with-different-link-formats#one section|another name]]\n\nalias [[first-document|an alias]]\n\ndupilcated wikilink to file [[first-document]]\n\n# one section"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/file-with-explicit-and-implicit-link-references.md",
    "content": "# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference].\nI also want a [[first-document]].\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkrefenrece]: https://foambubble.github.io/\n[first-document]: first-document 'First Document'\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/file-with-explicit-link-references.md",
    "content": "# File with explicit link references\n\nA Bug [^footerlink]. Here is [Another link][linkreference]\n\n[^footerlink]: https://foambubble.github.io/\n\n[linkrefenrece]: https://foambubble.github.io/\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/file-with-only-frontmatter.md",
    "content": "---\nnoTitle: This frontmatter doesn't contain any title\n---"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/file-without-title.md",
    "content": "This file is missing a title\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/first-document.md",
    "content": "# First Document\n\nHere's some [unrelated] content.\n\n[unrelated]: http://unrelated.com 'This link should not be changed'\n\n[[file-without-title]]\n\n[second-document]: second-document 'Second Document'\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/index.md",
    "content": "# Index\n\nThis file is intentionally missing the link reference definitions\n\n[[first-document]]\n\n[[second-document]]\n\n[[file-without-title]]\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/second-document.md",
    "content": "# Second Document\n\nThis is just a link target for now.\n\nWe can use it for other things later if needed.\n\n[first-document]: first-document 'First Document'\n"
  },
  {
    "path": "packages/foam-vscode/test-data/__scaffold__/third-document.md",
    "content": "# Third Document\n\nAll the link references are correct in this file.\n\n[[first-document]]\n\n[[second-document]]\n\n[//begin]: # \"Autogenerated link references for markdown compatibility\"\n[first-document]: first-document \"First Document\"\n[second-document]: second-document \"Second Document\"\n[//end]: # \"Autogenerated link references\"\n"
  },
  {
    "path": "packages/foam-vscode/test-data/test-config/enable-plugins/config.json",
    "content": "{\n  \"experimental\": {\n    \"localPlugins\": {\n      \"enabled\": true\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/test-data/test-config/folder1/config.json",
    "content": "{\n  \"feature1\": {\n    \"setting1\": {\n      \"value\": true,\n      \"extraValue\": \"go foam\"\n    }\n  },\n  \"feature2\": {\n    \"value\": 12\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/test-data/test-config/folder2/config.json",
    "content": "{\n  \"feature1\": {\n    \"setting1\": {\n      \"value\": false,\n      \"value2\": \"hello\"\n    }\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/test-data/test-datastore/docs/file-in-nm.md",
    "content": ""
  },
  {
    "path": "packages/foam-vscode/test-data/test-datastore/file-a.md",
    "content": ""
  },
  {
    "path": "packages/foam-vscode/test-data/test-datastore/info/docs/file-in-sub-nm.md",
    "content": ""
  },
  {
    "path": "packages/foam-vscode/test-data/test-datastore/info/file-b.md",
    "content": ""
  },
  {
    "path": "packages/foam-vscode/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.base.json\",\n  \"compilerOptions\": {\n    \"moduleResolution\": \"node\",\n    \"esModuleInterop\": true,\n    \"outDir\": \"out\",\n    \"lib\": [\"ES2019\", \"es2020.string\", \"DOM\"],\n    \"sourceMap\": true,\n    \"strict\": false,\n    \"downlevelIteration\": true\n  },\n  \"include\": [\"src\", \"types\"],\n  \"exclude\": [\"node_modules\", \".vscode-test\"]\n}\n"
  },
  {
    "path": "packages/foam-vscode/types/utils.d.ts",
    "content": "declare module 'remark-wiki-link';\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/build.cjs",
    "content": "const path = require('path');\nconst fs = require('fs');\nconst esbuild = require('esbuild');\n\nconst dir = __dirname;\nconst production = process.argv.includes('--production');\nconst watch = process.argv.includes('--watch');\nconst libOnly = process.argv.includes('--lib');\nconst vscodeOnly = process.argv.includes('--vscode');\nconst buildLib = !vscodeOnly;\nconst buildVscode = !libOnly;\n\nconst esbuildProblemMatcherPlugin = {\n  name: 'esbuild-problem-matcher',\n  setup(build) {\n    build.onStart(() => console.log('[watch] build started'));\n    build.onEnd(result => {\n      result.errors.forEach(({ text, location }) => {\n        console.error(`✘ [ERROR] ${text}`);\n        if (location) console.error(`    ${location.file}:${location.line}:${location.column}:`);\n      });\n      console.log('[watch] build finished');\n    });\n  },\n};\n\nasync function buildLibTarget() {\n  console.log('Building lib (ESM)...');\n  await esbuild.build({\n    entryPoints: [\n      path.join(dir, 'src/foam-graph.ts'),\n      path.join(dir, 'src/protocol.ts'),\n    ],\n    bundle: true,\n    format: 'esm',\n    outdir: path.join(dir, 'dist'),\n    platform: 'browser',\n    external: ['lit', 'lit/*', 'lit/decorators.js'],\n    minify: production,\n    sourcemap: !production,\n    sourcesContent: false,\n  });\n}\n\nasync function buildVscodeTarget() {\n  const outdir = path.join(dir, '../../static/dataviz');\n\n  // Ensure output directory exists\n  fs.mkdirSync(outdir, { recursive: true });\n\n  // Copy index.html\n  fs.copyFileSync(path.join(dir, 'index.html'), path.join(outdir, 'index.html'));\n\n  // Copy protocol.ts into the extension source so the extension can import it\n  // without tsconfig path aliases (the file is gitignored as it is generated)\n  const protocolSrc = fs.readFileSync(path.join(dir, 'src/protocol.ts'), 'utf8');\n  const header =\n    '// This file is auto-generated from webview-ui/graph/src/protocol.ts — do not edit directly.\\n\\n';\n  fs.writeFileSync(\n    path.join(dir, '../../src/features/panels/dataviz/graph-protocol.ts'),\n    header + protocolSrc\n  );\n\n  const ctx = await esbuild.context({\n    entryPoints: [\n      path.join(dir, 'src/main.ts'),\n      path.join(dir, 'src/main.css'),\n    ],\n    bundle: true,\n    minify: production,\n    sourcemap: !production,\n    sourcesContent: false,\n    outdir,\n    entryNames: '[name]',\n    platform: 'browser',\n    format: 'iife',\n    logLevel: 'silent',\n    plugins: [esbuildProblemMatcherPlugin],\n  });\n\n  if (watch) {\n    await ctx.watch();\n  } else {\n    await ctx.rebuild();\n    await ctx.dispose();\n  }\n}\n\nasync function main() {\n  if (buildLib) await buildLibTarget();\n  if (buildVscode) await buildVscodeTarget();\n}\n\nmain().catch(e => {\n  console.error(e);\n  process.exit(1);\n});\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/index.html",
    "content": "<!DOCTYPE html>\n<html>\n  <head>\n    <meta charset=\"utf-8\" />\n    <link data-replace href=\"./main.css\" rel=\"stylesheet\" />\n  </head>\n  <body>\n    <div id=\"app\"></div>\n    <script data-replace src=\"./main.js\"></script>\n  </body>\n</html>\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/package.json",
    "content": "{\n  \"name\": \"@foam/graph\",\n  \"version\": \"0.33.0\",\n  \"description\": \"Foam graph visualization web component\",\n  \"license\": \"MIT\",\n  \"type\": \"module\",\n  \"exports\": {\n    \".\": \"./src/foam-graph.ts\",\n    \"./protocol\": \"./src/protocol.ts\"\n  },\n  \"publishConfig\": {\n    \"exports\": {\n      \".\": \"./dist/foam-graph.js\",\n      \"./protocol\": \"./dist/protocol.js\"\n    }\n  },\n  \"files\": [\n    \"dist/\",\n    \"src/\",\n    \"!src/main.ts\",\n    \"!src/main.css\"\n  ],\n  \"scripts\": {\n    \"build\": \"node build.cjs\",\n    \"build:lib\": \"node build.cjs --lib\",\n    \"build:vscode\": \"node build.cjs --vscode\",\n    \"watch\": \"node build.cjs --vscode --watch\",\n    \"test\": \"vitest run\"\n  },\n  \"peerDependencies\": {\n    \"lit\": \"^3.0.0\"\n  },\n  \"devDependencies\": {\n    \"d3-color\": \"^3.1.0\",\n    \"d3-force\": \"^3.0.0\",\n    \"d3-scale\": \"^4.0.0\",\n    \"esbuild\": \"^0.25.0\",\n    \"force-graph\": \"^1.49.5\",\n    \"happy-dom\": \"^17.0.0\",\n    \"lit\": \"^3.0.0\",\n    \"typescript\": \"^4.9.5\",\n    \"vitest\": \"^3.0.0\"\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/components/control-panel.ts",
    "content": "import { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport { getNodeTypeColor } from '../lib/colors';\nimport type { ResolvedStyle, Forces, Selection } from '../lib/types';\n\n@customElement('foam-control-panel')\nexport class ControlPanel extends LitElement {\n  static styles = css`\n    :host {\n      position: fixed;\n      top: 8px;\n      right: 8px;\n      width: 220px;\n      display: block;\n      background: var(--vscode-sideBar-background, #252526);\n      border: 1px solid var(--vscode-panel-border, #454545);\n      border-radius: 4px;\n      font-size: 11px;\n      color: var(--vscode-foreground, #ccc);\n      z-index: 10;\n      user-select: none;\n    }\n\n    details {\n      border-bottom: 1px solid var(--vscode-panel-border, #454545);\n    }\n\n    details:last-child {\n      border-bottom: none;\n    }\n\n    summary {\n      padding: 5px 8px;\n      cursor: pointer;\n      font-weight: 600;\n      list-style: none;\n      display: flex;\n      align-items: center;\n      gap: 4px;\n      background: var(--vscode-sideBarSectionHeader-background, #2d2d2d);\n    }\n\n    summary::before {\n      content: '▶';\n      font-size: 8px;\n      transition: transform 0.1s;\n    }\n\n    details[open] summary::before {\n      transform: rotate(90deg);\n    }\n\n    .section-content {\n      padding: 4px 8px 6px;\n      display: flex;\n      flex-direction: column;\n      gap: 4px;\n    }\n\n    .checkbox-row {\n      display: flex;\n      align-items: center;\n      gap: 6px;\n      cursor: pointer;\n    }\n\n    .checkbox-row input[type='checkbox'] {\n      margin: 0;\n      cursor: pointer;\n    }\n\n    .slider-row {\n      display: grid;\n      grid-template-columns: 60px 1fr 32px;\n      align-items: center;\n      gap: 4px;\n    }\n\n    .slider-row input[type='range'] {\n      width: 100%;\n      margin: 0;\n      accent-color: var(--vscode-focusBorder, #007acc);\n    }\n\n    .value {\n      text-align: right;\n      font-variant-numeric: tabular-nums;\n      opacity: 0.8;\n    }\n\n    .select-row {\n      display: grid;\n      grid-template-columns: 60px 1fr;\n      align-items: center;\n      gap: 4px;\n    }\n\n    select {\n      background: var(--vscode-dropdown-background, #3c3c3c);\n      color: var(--vscode-dropdown-foreground, #ccc);\n      border: 1px solid var(--vscode-dropdown-border, #454545);\n      padding: 2px 4px;\n      font-size: 11px;\n    }\n  `;\n\n  @property({ type: Object }) style: ResolvedStyle = {} as ResolvedStyle;\n  @property({ type: Object }) showNodesOfType: Record<string, boolean> = {};\n  @property({ type: Number }) textFade: number = 3.8;\n  @property({ type: Number }) nodeFontSizeMultiplier: number = 1;\n  @property({ type: Object }) forces: Forces = { collide: 2, repel: 30, link: 30, velocityDecay: 0.4 };\n  @property({ type: Object }) selection: Selection = { neighborDepth: 1, enableRefocus: true, enableZoom: true };\n\n  private get _nodeTypes() {\n    return Object.keys(this.showNodesOfType).sort();\n  }\n\n  render() {\n    return html`\n      <details open>\n        <summary>Filter by type</summary>\n        <div class=\"section-content\">\n          ${this._nodeTypes.map(type => html`\n            <label class=\"checkbox-row\">\n              <input\n                type=\"checkbox\"\n                .checked=${this.showNodesOfType[type]}\n                @change=${(e: Event) => this._emitShowNodesOfTypeChange(type, (e.target as HTMLInputElement).checked)}\n              />\n              <span style=\"color: ${getNodeTypeColor(type, this.style)}\">${type}</span>\n            </label>\n          `)}\n        </div>\n      </details>\n\n      <details open>\n        <summary>Appearance</summary>\n        <div class=\"section-content\">\n          <label class=\"slider-row\">\n            <span>Text Fade</span>\n            <input\n              type=\"range\"\n              min=\"0\" max=\"5\" step=\"0.1\"\n              .value=${String(this.textFade)}\n              @input=${(e: Event) => this._emit('text-fade-change', parseFloat((e.target as HTMLInputElement).value))}\n            />\n            <span class=\"value\">${this.textFade.toFixed(1)}</span>\n          </label>\n          <label class=\"slider-row\">\n            <span>Font Size</span>\n            <input\n              type=\"range\"\n              min=\"0.5\" max=\"3\" step=\"0.1\"\n              .value=${String(this.nodeFontSizeMultiplier)}\n              @input=${(e: Event) => this._emit('font-size-multiplier-change', parseFloat((e.target as HTMLInputElement).value))}\n            />\n            <span class=\"value\">${this.nodeFontSizeMultiplier.toFixed(1)}×</span>\n          </label>\n          <label class=\"select-row\">\n            <span>Color by</span>\n            <select\n              .value=${this.style.colorMode ?? 'none'}\n              @change=${(e: Event) => this._emit('style-change', { colorMode: (e.target as HTMLSelectElement).value as 'none' | 'directory' })}\n            >\n              <option value=\"none\">None</option>\n              <option value=\"directory\">Directory</option>\n            </select>\n          </label>\n        </div>\n      </details>\n\n      <details>\n        <summary>Forces</summary>\n        <div class=\"section-content\">\n          <label class=\"slider-row\">\n            <span>Collide</span>\n            <input\n              type=\"range\"\n              min=\"0\" max=\"4\" step=\"0.1\"\n              .value=${String(this.forces.collide)}\n              @input=${(e: Event) => this._emitForcesChange({ collide: parseFloat((e.target as HTMLInputElement).value) })}\n            />\n            <span class=\"value\">${this.forces.collide.toFixed(1)}</span>\n          </label>\n          <label class=\"slider-row\">\n            <span>Repel</span>\n            <input\n              type=\"range\"\n              min=\"0\" max=\"200\" step=\"1\"\n              .value=${String(this.forces.repel)}\n              @input=${(e: Event) => this._emitForcesChange({ repel: parseFloat((e.target as HTMLInputElement).value) })}\n            />\n            <span class=\"value\">${this.forces.repel}</span>\n          </label>\n          <label class=\"slider-row\">\n            <span>Link Dist</span>\n            <input\n              type=\"range\"\n              min=\"0\" max=\"100\" step=\"1\"\n              .value=${String(this.forces.link)}\n              @input=${(e: Event) => this._emitForcesChange({ link: parseFloat((e.target as HTMLInputElement).value) })}\n            />\n            <span class=\"value\">${this.forces.link}</span>\n          </label>\n          <label class=\"slider-row\">\n            <span>Velocity</span>\n            <input\n              type=\"range\"\n              min=\"0\" max=\"1\" step=\"0.01\"\n              .value=${String(this.forces.velocityDecay)}\n              @input=${(e: Event) => this._emitForcesChange({ velocityDecay: parseFloat((e.target as HTMLInputElement).value) })}\n            />\n            <span class=\"value\">${this.forces.velocityDecay.toFixed(2)}</span>\n          </label>\n        </div>\n      </details>\n\n      <details>\n        <summary>Selection</summary>\n        <div class=\"section-content\">\n          <label class=\"slider-row\">\n            <span>Depth</span>\n            <input\n              type=\"range\"\n              min=\"1\" max=\"5\" step=\"1\"\n              .value=${String(this.selection.neighborDepth)}\n              @input=${(e: Event) => this._emitSelectionChange({ neighborDepth: parseInt((e.target as HTMLInputElement).value) })}\n            />\n            <span class=\"value\">${this.selection.neighborDepth}</span>\n          </label>\n          <label class=\"checkbox-row\">\n            <input\n              type=\"checkbox\"\n              .checked=${this.selection.enableRefocus}\n              @change=${(e: Event) => this._emitSelectionChange({ enableRefocus: (e.target as HTMLInputElement).checked })}\n            />\n            <span>Refocus on select</span>\n          </label>\n          <label class=\"checkbox-row\">\n            <input\n              type=\"checkbox\"\n              .checked=${this.selection.enableZoom}\n              @change=${(e: Event) => this._emitSelectionChange({ enableZoom: (e.target as HTMLInputElement).checked })}\n            />\n            <span>Zoom on select</span>\n          </label>\n        </div>\n      </details>\n    `;\n  }\n\n  private _emit(eventName: string, detail: unknown) {\n    this.dispatchEvent(new CustomEvent(eventName, { detail, bubbles: true, composed: true }));\n  }\n\n  private _emitShowNodesOfTypeChange(type: string, checked: boolean) {\n    this._emit('show-nodes-of-type-change', { ...this.showNodesOfType, [type]: checked });\n  }\n\n  private _emitForcesChange(patch: Partial<Forces>) {\n    this._emit('forces-change', { ...this.forces, ...patch });\n  }\n\n  private _emitSelectionChange(patch: Partial<Selection>) {\n    this._emit('selection-change', { ...this.selection, ...patch });\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'foam-control-panel': ControlPanel;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/components/graph-canvas.ts",
    "content": "import { LitElement, html, css } from 'lit';\nimport { customElement, property } from 'lit/decorators.js';\nimport ForceGraph from 'force-graph';\nimport { forceX, forceY, forceCollide, forceManyBody, forceLink } from 'd3-force';\nimport { scaleLinear } from 'd3-scale';\nimport { Painter } from '../lib/painter';\nimport {\n  computeFocusSets,\n  getNodeState,\n  getLinkState,\n  getLinkNodeId,\n} from '../lib/graph-utils';\nimport { getNodeFillAndBorder, getLinkColor } from '../lib/colors';\nimport type {\n  AugmentedGraph,\n  AugmentedLink,\n  ResolvedStyle,\n  Forces,\n  Selection,\n} from '../lib/types';\n\n@customElement('foam-graph-canvas')\nexport class GraphCanvas extends LitElement {\n  static styles = css`\n    :host {\n      display: block;\n      position: absolute;\n      inset: 0;\n    }\n  `;\n\n  @property({ type: Object }) augmentedGraph: AugmentedGraph | null = null;\n  @property({ type: Object }) style: ResolvedStyle = {} as ResolvedStyle;\n  @property({ type: Object }) showNodesOfType: Record<string, boolean> = {};\n  @property({ type: Object }) forces: Forces = { collide: 2, repel: 30, link: 30, velocityDecay: 0.4 };\n  @property({ type: Object }) selection: Selection = { neighborDepth: 1, enableRefocus: true, enableZoom: true };\n  @property({ type: Number }) textFade: number = 3.8;\n  @property({ type: Number }) nodeFontSizeMultiplier: number = 1;\n\n  // Mutable rendering state — closed over by canvas callbacks\n  private rs = {\n    augmented: null as AugmentedGraph | null,\n    data: { nodes: [] as { id: string }[], links: [] as AugmentedLink[] },\n    selectedNodes: new Set<string>(),\n    hoverNode: null as string | null,\n    focusNodes: new Set<string>(),\n    focusLinks: new Set<AugmentedLink>(),\n    style: {} as ResolvedStyle,\n    showNodesOfType: {} as Record<string, boolean>,\n    forces: {} as Forces,\n    selection: {} as Selection,\n    textFade: 3.8,\n    nodeFontSizeMultiplier: 1,\n    colorMode: 'none' as 'none' | 'directory',\n  };\n\n  private readonly getNodeSize = scaleLinear().domain([0, 30]).range([0.5, 2]).clamp(true);\n  private readonly getNodeLabelOpacity = scaleLinear().domain([1.2, 2.0]).range([0, 1]).clamp(true);\n\n  private graphInstance: ReturnType<ReturnType<typeof ForceGraph>> | null = null;\n  private firstGraphLoad = true;\n\n  protected createRenderRoot() {\n    // Use shadow DOM but pass through so the force-graph canvas fills the host\n    return super.createRenderRoot();\n  }\n\n  render() {\n    return html`<div id=\"canvas-container\"></div>`;\n  }\n\n  firstUpdated() {\n    const container = this.shadowRoot!.getElementById('canvas-container') as HTMLDivElement;\n    const painter = new Painter();\n\n    this.graphInstance = ForceGraph()(container)\n      .graphData(this.rs.data as any)\n      .backgroundColor(this.rs.style.background || '#202020')\n      .linkHoverPrecision(8)\n      .d3Force('x', forceX())\n      .d3Force('y', forceY())\n      .d3Force('collide', forceCollide(4 /* default nodeRelSize */ * this.rs.forces.collide || 8))\n      .d3Force('charge', forceManyBody().strength(-(this.rs.forces.repel || 30)))\n      .d3Force('link', forceLink(this.rs.data.links as any).distance(this.rs.forces.link || 30))\n      .d3VelocityDecay(1 - (this.rs.forces.velocityDecay ?? 0.4))\n      .linkWidth(() => this.rs.style.lineWidth)\n      .linkDirectionalParticles(1)\n      .linkDirectionalParticleWidth(link => {\n        const state = getLinkState(\n          link as AugmentedLink,\n          this.rs.focusNodes,\n          this.rs.focusLinks\n        );\n        return state === 'highlighted' ? this.rs.style.particleWidth : 0;\n      })\n      .nodeCanvasObject((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {\n        const info = this.rs.augmented?.nodeInfo[node.id];\n        if (!info) return;\n\n        const size = this.getNodeSize(info.neighbors.length);\n        const state = getNodeState(\n          node.id,\n          this.rs.selectedNodes,\n          this.rs.hoverNode,\n          this.rs.focusNodes\n        );\n        const { fill, border } = getNodeFillAndBorder(\n          info,\n          state,\n          this.rs.style,\n          this.rs.colorMode\n        );\n        const fontSize =\n          (this.rs.style.fontSize * this.rs.nodeFontSizeMultiplier) / globalScale;\n        const opacity =\n          state === 'highlighted'\n            ? 1\n            : state === 'regular'\n              ? this.getNodeLabelOpacity(globalScale)\n              : Math.min(this.getNodeLabelOpacity(globalScale), fill.opacity);\n\n        const textColor = fill.copy({ opacity });\n\n        painter\n          .circle(node.x, node.y, size, fill, border)\n          .text(\n            info.title,\n            node.x,\n            node.y + size + 1,\n            fontSize,\n            this.rs.style.fontFamily,\n            textColor as any\n          );\n      })\n      .onRenderFramePost((ctx: CanvasRenderingContext2D) => {\n        painter.paint(ctx);\n      })\n      .linkColor((link: any) => {\n        const augLink = link as AugmentedLink;\n        const state = getLinkState(augLink, this.rs.focusNodes, this.rs.focusLinks);\n        const srcInfo = this.rs.augmented?.nodeInfo[getLinkNodeId(augLink.source)];\n        const tgtInfo = this.rs.augmented?.nodeInfo[getLinkNodeId(augLink.target)];\n        return getLinkColor(\n          state,\n          srcInfo?.type ?? 'note',\n          tgtInfo?.type ?? 'note',\n          this.rs.style\n        );\n      })\n      .onNodeHover((node: any) => {\n        this.rs.hoverNode = node?.id ?? null;\n        this._updateFocusSets();\n      })\n      .onNodeClick((node: any, event: MouseEvent) => {\n        const isAppend = event.getModifierState('Shift');\n        this._selectNode(node.id, isAppend);\n        this.dispatchEvent(new CustomEvent('node-click', { detail: node.id }));\n      })\n      .onBackgroundClick((event: MouseEvent) => {\n        if (!event.getModifierState('Shift')) {\n          this._selectNode(null, false);\n        }\n      });\n\n    window.addEventListener('resize', () => {\n      this.graphInstance?.width(window.innerWidth).height(window.innerHeight);\n    });\n  }\n\n  disconnectedCallback() {\n    super.disconnectedCallback();\n    (this.graphInstance as any)?._destructor?.();\n  }\n\n  updated(changed: Map<string, unknown>) {\n    if (changed.has('style')) {\n      this.rs.style = this.style;\n      this.rs.colorMode = this.style.colorMode;\n      this.graphInstance?.backgroundColor(this.style.background);\n    }\n\n    if (changed.has('showNodesOfType')) {\n      this.rs.showNodesOfType = this.showNodesOfType;\n      if (this.rs.augmented) this._updateGraphData();\n    }\n\n    if (changed.has('forces')) {\n      this.rs.forces = this.forces;\n      if (this.graphInstance) {\n        (this.graphInstance.d3Force('collide') as any)?.radius(\n          this.graphInstance.nodeRelSize() * this.forces.collide\n        );\n        (this.graphInstance.d3Force('charge') as any)?.strength(-this.forces.repel);\n        (this.graphInstance.d3Force('link') as any)?.distance(this.forces.link);\n        this.graphInstance.d3VelocityDecay(1 - this.forces.velocityDecay);\n        this.graphInstance.d3ReheatSimulation();\n      }\n    }\n\n    if (changed.has('selection')) {\n      this.rs.selection = this.selection;\n      this._updateFocusSets();\n    }\n\n    if (changed.has('textFade')) {\n      this.rs.textFade = this.textFade;\n      const invertedValue = 5 - this.textFade;\n      this.getNodeLabelOpacity.domain([invertedValue, invertedValue + 0.8]);\n    }\n\n    if (changed.has('nodeFontSizeMultiplier')) {\n      this.rs.nodeFontSizeMultiplier = this.nodeFontSizeMultiplier;\n    }\n\n    if (changed.has('augmentedGraph')) {\n      if (!this.augmentedGraph) return;\n      this.rs.augmented = this.augmentedGraph;\n      this._updateGraphData();\n      this._updateFocusSets();\n\n      if (this.firstGraphLoad && this.graphInstance) {\n        this.firstGraphLoad = false;\n        this.graphInstance.zoom(this.graphInstance.zoom() * 1.5);\n        this.graphInstance.cooldownTicks(100);\n        this.graphInstance.onEngineStop(() => {\n          this.graphInstance!.onEngineStop(() => {});\n          this.graphInstance!.zoomToFit(500);\n        });\n      }\n    }\n  }\n\n  selectNote(noteId: string) {\n    if (!this.graphInstance) return;\n    const nodes = this.graphInstance.graphData().nodes as any[];\n    const node = nodes.find(n => n.id === noteId);\n    if (node) {\n      if (this.rs.selection.enableRefocus) {\n        this.graphInstance.centerAt(node.x, node.y, 300);\n      }\n      if (this.rs.selection.enableZoom) {\n        this.graphInstance.zoom(3, 300);\n      }\n      this._selectNode(noteId, false);\n    }\n  }\n\n  private _updateFocusSets() {\n    if (!this.rs.augmented) return;\n    const { focusNodes, focusLinks } = computeFocusSets(\n      this.rs.selectedNodes,\n      this.rs.hoverNode,\n      this.rs.selection.neighborDepth,\n      this.rs.augmented.nodeInfo,\n      this.rs.augmented.links\n    );\n    this.rs.focusNodes = focusNodes;\n    this.rs.focusLinks = focusLinks;\n  }\n\n  private _selectNode(nodeId: string | null, isAppend: boolean) {\n    if (!isAppend) this.rs.selectedNodes.clear();\n    if (nodeId != null) this.rs.selectedNodes.add(nodeId);\n    this._updateFocusSets();\n  }\n\n  private _updateGraphData() {\n    if (!this.rs.augmented || !this.graphInstance) return;\n\n    const nodeIdsToAdd = new Set(\n      Object.values(this.rs.augmented.nodeInfo)\n        .filter(n => this.rs.showNodesOfType[n.type])\n        .map(n => n.id)\n    );\n\n    const nodeIdsToRemove = new Set<string>();\n    for (const node of this.rs.data.nodes) {\n      if (nodeIdsToAdd.has(node.id)) {\n        nodeIdsToAdd.delete(node.id);\n      } else {\n        nodeIdsToRemove.add(node.id);\n      }\n    }\n\n    for (const id of nodeIdsToRemove) {\n      const idx = this.rs.data.nodes.findIndex(n => n.id === id);\n      if (idx !== -1) this.rs.data.nodes.splice(idx, 1);\n    }\n    for (const id of nodeIdsToAdd) {\n      this.rs.data.nodes.push({ id });\n    }\n\n    const nodeIdSet = new Set(this.rs.data.nodes.map(n => n.id));\n    this.rs.data.links = this.rs.augmented.links\n      .filter(link => {\n        return (\n          nodeIdSet.has(getLinkNodeId(link.source)) &&\n          nodeIdSet.has(getLinkNodeId(link.target))\n        );\n      })\n      .map(link => ({ ...link }));\n\n    this.rs.hoverNode =\n      this.rs.augmented.nodeInfo[this.rs.hoverNode ?? ''] != null\n        ? this.rs.hoverNode\n        : null;\n    this.rs.selectedNodes = new Set(\n      [...this.rs.selectedNodes].filter(\n        id => this.rs.augmented!.nodeInfo[id] != null\n      )\n    );\n\n    this.graphInstance.graphData(this.rs.data as any);\n    (this.graphInstance.d3Force('link') as any)?.links(this.rs.data.links);\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'foam-graph-canvas': GraphCanvas;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/foam-graph.ts",
    "content": "import { LitElement, html, css, nothing } from 'lit';\nimport { customElement, property, state } from 'lit/decorators.js';\nimport { getDefaultStyle } from './lib/defaults';\nimport { augmentGraphInfo } from './lib/graph-utils';\nimport type { GraphData, StylePayload } from './protocol';\nimport type { AugmentedGraph, ResolvedStyle, Forces, Selection } from './lib/types';\nimport './components/graph-canvas';\nimport './components/control-panel';\n\n@customElement('foam-graph')\nexport class FoamGraph extends LitElement {\n  static styles = css`\n    :host {\n      display: block;\n      position: relative;\n      width: 100%;\n      height: 100%;\n    }\n  `;\n\n  // Public API\n  @property({ type: Object }) graphData: GraphData | null = null;\n  @property({ type: Object }) graphStyle: StylePayload | null = null;\n\n  // Internal control state\n  @state() private augmentedGraph: AugmentedGraph | null = null;\n  @state() private showNodesOfType: Record<string, boolean> = {\n    placeholder: true,\n    image: false,\n    attachment: false,\n    note: true,\n    tag: true,\n  };\n  @state() private textFade: number = 3.8;\n  @state() private nodeFontSizeMultiplier: number = 1;\n  @state() private forces: Forces = { collide: 2, repel: 30, link: 30, velocityDecay: 0.4 };\n  @state() private selection: Selection = { neighborDepth: 1, enableRefocus: true, enableZoom: true };\n  @state() private localStylePatch: StylePayload = {};\n\n  private get resolvedStyle(): ResolvedStyle {\n    return this._resolveStyle(this._mergedStylePayload);\n  }\n\n  private get _mergedStylePayload(): StylePayload {\n    // graphStyle from the outside takes precedence; localStylePatch layers on top\n    return {\n      ...this.graphStyle,\n      ...this.localStylePatch,\n      style: { ...this.graphStyle?.style, ...this.localStylePatch?.style },\n    };\n  }\n\n  private _resolveStyle(payload: StylePayload | null): ResolvedStyle {\n    const defaults = getDefaultStyle();\n    if (!payload) return defaults;\n    return {\n      ...defaults,\n      ...payload.style,\n      lineColor:\n        payload.style?.lineColor ||\n        payload.style?.node?.note ||\n        defaults.lineColor,\n      node: {\n        ...defaults.node,\n        ...payload.style?.node,\n      },\n      colorMode: payload.colorMode ?? defaults.colorMode,\n    };\n  }\n\n  updated(changed: Map<string, unknown>) {\n    if (changed.has('graphData') && this.graphData) {\n      this.augmentedGraph = augmentGraphInfo(this.graphData);\n      this._syncNodeTypes(this.augmentedGraph);\n    }\n  }\n\n  private _syncNodeTypes(graph: AugmentedGraph) {\n    const types = new Set(Object.values(graph.nodeInfo).map(n => n.type));\n    const updated = { ...this.showNodesOfType };\n    let changed = false;\n\n    for (const type of types) {\n      if (updated[type] == null) {\n        updated[type] = type !== 'image' && type !== 'attachment';\n        changed = true;\n      }\n    }\n    for (const type of Object.keys(updated)) {\n      if (!types.has(type)) {\n        delete updated[type];\n        changed = true;\n      }\n    }\n\n    if (changed) this.showNodesOfType = updated;\n  }\n\n  render() {\n    const resolved = this.resolvedStyle;\n    return html`\n      <foam-graph-canvas\n        .augmentedGraph=${this.augmentedGraph}\n        .style=${resolved}\n        .showNodesOfType=${this.showNodesOfType}\n        .forces=${this.forces}\n        .selection=${this.selection}\n        .textFade=${this.textFade}\n        .nodeFontSizeMultiplier=${this.nodeFontSizeMultiplier}\n        @node-click=${(e: CustomEvent) => this._onNodeClick(e.detail)}\n      ></foam-graph-canvas>\n      <foam-control-panel\n        .style=${resolved}\n        .showNodesOfType=${this.showNodesOfType}\n        .textFade=${this.textFade}\n        .nodeFontSizeMultiplier=${this.nodeFontSizeMultiplier}\n        .forces=${this.forces}\n        .selection=${this.selection}\n        @style-change=${(e: CustomEvent) => this._onStyleChange(e.detail)}\n        @show-nodes-of-type-change=${(e: CustomEvent) => (this.showNodesOfType = e.detail)}\n        @text-fade-change=${(e: CustomEvent) => (this.textFade = e.detail)}\n        @font-size-multiplier-change=${(e: CustomEvent) => (this.nodeFontSizeMultiplier = e.detail)}\n        @forces-change=${(e: CustomEvent) => (this.forces = e.detail)}\n        @selection-change=${(e: CustomEvent) => (this.selection = e.detail)}\n      ></foam-control-panel>\n    `;\n  }\n\n  selectNote(noteId: string) {\n    const canvas = this.shadowRoot?.querySelector('foam-graph-canvas') as any;\n    canvas?.selectNote(noteId);\n  }\n\n  private _onNodeClick(nodeId: string) {\n    this.dispatchEvent(new CustomEvent('node-click', { detail: nodeId, bubbles: true, composed: true }));\n  }\n\n  private _onStyleChange(patch: Partial<ResolvedStyle>) {\n    const { colorMode, ...styleProps } = patch as any;\n    this.localStylePatch = {\n      ...this.localStylePatch,\n      ...(colorMode !== undefined ? { colorMode } : {}),\n      style: { ...this.localStylePatch?.style, ...styleProps },\n    };\n  }\n}\n\ndeclare global {\n  interface HTMLElementTagNameMap {\n    'foam-graph': FoamGraph;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/colors.ts",
    "content": "import { rgb, hsl } from 'd3-color';\nimport type { RGBColor } from 'd3-color';\nimport type { AugmentedNode, ResolvedStyle } from './types';\n\nexport function getNodeTypeColor(type: string, style: ResolvedStyle): string {\n  return style.node[type] ?? style.node['note'];\n}\n\nexport function hashString(str: string): number {\n  let hash = 0;\n  for (let i = 0; i < str.length; i++) {\n    const char = str.charCodeAt(i);\n    hash = ((hash << 5) - hash) + char;\n  }\n  return Math.abs(hash);\n}\n\nfunction hashToHSL(hash: number): string {\n  const hue = hash % 360;\n  const saturation = 50 + (hash % 20);\n  const lightness = 50 + ((hash >> 8) % 20);\n  return `hsl(${hue}, ${saturation}%, ${lightness}%)`;\n}\n\nexport function getDirectoryColor(nodeId: string): string {\n  let currentPath = nodeId;\n  const lastSegment = currentPath.split('/').pop();\n  if (lastSegment?.includes('.')) {\n    currentPath = currentPath.substring(0, currentPath.lastIndexOf('/'));\n  }\n  if (currentPath.length > 0 && !currentPath.endsWith('/')) {\n    currentPath += '/';\n  }\n  return hashToHSL(hashString(currentPath));\n}\n\nexport function getNodeFillAndBorder(\n  nodeInfo: AugmentedNode,\n  state: 'regular' | 'highlighted' | 'lessened',\n  style: ResolvedStyle,\n  colorMode: 'none' | 'directory'\n): { fill: RGBColor; border: RGBColor } {\n  let baseColor: string;\n\n  if (nodeInfo.properties.color) {\n    baseColor = nodeInfo.properties.color as string;\n  } else if (colorMode === 'directory') {\n    baseColor = getDirectoryColor(nodeInfo.id);\n  } else {\n    baseColor = getNodeTypeColor(nodeInfo.type, style);\n  }\n\n  const typeFill = rgb(baseColor);\n\n  switch (state) {\n    case 'regular':\n      return { fill: typeFill, border: typeFill };\n    case 'lessened': {\n      const transparent = typeFill.copy({ opacity: 0.05 }) as RGBColor;\n      return { fill: transparent, border: transparent };\n    }\n    case 'highlighted':\n      return { fill: typeFill, border: rgb(style.highlightedForeground) };\n  }\n}\n\nexport function getLinkColor(\n  linkState: 'regular' | 'highlighted' | 'lessened',\n  sourceType: string,\n  targetType: string,\n  style: ResolvedStyle\n): string {\n  switch (linkState) {\n    case 'regular':\n      if (sourceType === 'tag' && targetType === 'tag') {\n        return getNodeTypeColor('tag', style);\n      }\n      return style.lineColor;\n    case 'highlighted':\n      return style.highlightedForeground;\n    case 'lessened':\n      return hsl(style.lineColor).copy({ opacity: 0.5 }).toString();\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/defaults.ts",
    "content": "import type { ResolvedStyle } from './types';\n\nfunction getCSSVar(name: string): string {\n  return getComputedStyle(document.documentElement).getPropertyValue(name).trim();\n}\n\nexport function getDefaultStyle(): ResolvedStyle {\n  return {\n    background: getCSSVar('--vscode-panel-background') || '#202020',\n    fontSize: parseInt(getCSSVar('--vscode-font-size') || '12') - 2,\n    fontFamily: 'Sans-Serif',\n    lineColor: getCSSVar('--vscode-editor-foreground') || '#277da1',\n    lineWidth: 0.2,\n    particleWidth: 1.0,\n    highlightedForeground:\n      getCSSVar('--vscode-list-highlightForeground') || '#f9c74f',\n    node: {\n      note: getCSSVar('--vscode-editor-foreground') || '#277da1',\n      placeholder:\n        getCSSVar('--vscode-list-deemphasizedForeground') || '#545454',\n      tag: getCSSVar('--vscode-list-highlightForeground') || '#f9c74f',\n    },\n    colorMode: 'none',\n  };\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/graph-utils.test.ts",
    "content": "import { describe, it, expect } from 'vitest';\nimport { augmentGraphInfo } from './graph-utils';\nimport type { GraphData } from '../protocol';\n\nconst makeGraph = (overrides: Partial<GraphData> = {}): GraphData => ({\n  nodeInfo: {},\n  links: [],\n  ...overrides,\n});\n\ndescribe('augmentGraphInfo', () => {\n  it('should copy note nodes into the augmented graph', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': { id: 'note-1', type: 'note', title: 'Note 1', properties: {}, tags: [] },\n      },\n    });\n    const augmented = augmentGraphInfo(graph);\n    expect(augmented.nodeInfo['note-1']).toBeDefined();\n    expect(augmented.nodeInfo['note-1'].type).toBe('note');\n  });\n\n  it('should create tag nodes in nodeInfo for each unique tag', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n      },\n    });\n\n    const augmented = augmentGraphInfo(graph);\n\n    expect(augmented.nodeInfo['my-tag']).toBeDefined();\n    expect(augmented.nodeInfo['my-tag'].type).toBe('tag');\n  });\n\n  it('should expose tag type in nodeInfo so filter panel can show it', () => {\n    // This documents the fix: _syncNodeTypes must receive the augmented graph,\n    // not the raw graph, because tag nodes only exist after augmentation.\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n      },\n    });\n\n    const rawTypes = new Set(Object.values(graph.nodeInfo).map(n => n.type));\n    expect(rawTypes.has('tag')).toBe(false); // tag absent in raw data\n\n    const augmented = augmentGraphInfo(graph);\n    const augmentedTypes = new Set(Object.values(augmented.nodeInfo).map(n => n.type));\n    expect(augmentedTypes.has('tag')).toBe(true); // tag present after augmentation\n  });\n\n  it('should create intermediate nodes for hierarchical tags', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'parent/child' }],\n        },\n      },\n    });\n\n    const augmented = augmentGraphInfo(graph);\n\n    expect(augmented.nodeInfo['parent']).toBeDefined();\n    expect(augmented.nodeInfo['parent'].type).toBe('tag');\n    expect(augmented.nodeInfo['parent/child']).toBeDefined();\n    expect(augmented.nodeInfo['parent/child'].type).toBe('tag');\n  });\n\n  it('should create a link from tag to note', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n      },\n    });\n\n    const augmented = augmentGraphInfo(graph);\n\n    const tagToNoteLink = augmented.links.find(\n      l => l.source === 'my-tag' && l.target === 'note-1'\n    );\n    expect(tagToNoteLink).toBeDefined();\n  });\n\n  it('should deduplicate links', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n        'note-2': {\n          id: 'note-2', type: 'note', title: 'Note 2', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n      },\n      links: [{ source: 'note-1', target: 'note-1' }], // duplicate\n    });\n\n    const augmented = augmentGraphInfo(graph);\n\n    const dupes = augmented.links.filter(\n      l => l.source === 'note-1' && l.target === 'note-1'\n    );\n    expect(dupes.length).toBe(1);\n  });\n\n  it('should build neighbor relationships', () => {\n    const graph = makeGraph({\n      nodeInfo: {\n        'note-1': {\n          id: 'note-1', type: 'note', title: 'Note 1', properties: {},\n          tags: [{ label: 'my-tag' }],\n        },\n      },\n    });\n\n    const augmented = augmentGraphInfo(graph);\n\n    expect(augmented.nodeInfo['my-tag'].neighbors).toContain('note-1');\n    expect(augmented.nodeInfo['note-1'].neighbors).toContain('my-tag');\n  });\n});\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/graph-utils.ts",
    "content": "import type { GraphData } from '../protocol';\nimport type { AugmentedGraph, AugmentedNode, AugmentedLink } from './types';\n\nexport function getLinkNodeId(endpoint: string | AugmentedNode): string {\n  return typeof endpoint === 'object' ? endpoint.id : endpoint;\n}\n\nexport function augmentGraphInfo(graph: GraphData): AugmentedGraph {\n  const augmented: AugmentedGraph = { nodeInfo: {}, links: [] };\n\n  // Copy nodes with initialized neighbors/links\n  for (const node of Object.values(graph.nodeInfo)) {\n    augmented.nodeInfo[node.id] = { ...node, neighbors: [], links: [] };\n  }\n\n  // Copy links\n  augmented.links = graph.links.map(l => ({ ...l }));\n\n  // Process tags: create tag nodes and hierarchy links\n  for (const node of Object.values(augmented.nodeInfo)) {\n    if (!node.tags?.length) continue;\n    for (const tag of node.tags) {\n      const subtags = tag.label.split('/');\n      for (let i = 0; i < subtags.length; i++) {\n        const label = subtags.slice(0, i + 1).join('/');\n        if (!augmented.nodeInfo[label]) {\n          augmented.nodeInfo[label] = {\n            id: label,\n            title: label,\n            type: 'tag',\n            properties: {},\n            tags: [],\n            neighbors: [],\n            links: [],\n          };\n        }\n        if (i > 0) {\n          const parent = subtags.slice(0, i).join('/');\n          augmented.links.push({ source: parent, target: label });\n        }\n      }\n      augmented.links.push({ source: tag.label, target: node.id });\n    }\n  }\n\n  // Deduplicate links\n  const seen = new Set<string>();\n  augmented.links = augmented.links.filter(link => {\n    const key = `${getLinkNodeId(link.source)} -> ${getLinkNodeId(link.target)}`;\n    if (seen.has(key)) return false;\n    seen.add(key);\n    return true;\n  });\n\n  // Build neighbor relationships\n  for (const link of augmented.links) {\n    const a = augmented.nodeInfo[getLinkNodeId(link.source)];\n    const b = augmented.nodeInfo[getLinkNodeId(link.target)];\n    if (a && b) {\n      a.neighbors.push(b.id);\n      b.neighbors.push(a.id);\n      a.links.push(link);\n      b.links.push(link);\n    }\n  }\n\n  return augmented;\n}\n\nexport function getNeighbors(\n  nodeId: string,\n  depth: number,\n  nodeInfo: Record<string, AugmentedNode>\n): Set<string> {\n  let neighbors = new Set([nodeId]);\n  for (let i = 0; i < depth; i++) {\n    const newNeighbors = new Set<string>();\n    for (const id of neighbors) {\n      const node = nodeInfo[id];\n      if (node) {\n        for (const n of node.neighbors) newNeighbors.add(n);\n      } else {\n        console.debug(`getNeighbors: node '${id}' not found in nodeInfo, skipping.`);\n      }\n    }\n    for (const n of newNeighbors) neighbors.add(n);\n  }\n  return neighbors;\n}\n\nexport function computeFocusSets(\n  selectedNodes: Set<string>,\n  hoverNode: string | null,\n  neighborDepth: number,\n  nodeInfo: Record<string, AugmentedNode>,\n  links: AugmentedLink[]\n): { focusNodes: Set<string>; focusLinks: Set<AugmentedLink> } {\n  const focusNodes = new Set<string>();\n  const focusLinks = new Set<AugmentedLink>();\n\n  const nodesToProcess = [...selectedNodes, hoverNode].filter(\n    Boolean\n  ) as string[];\n\n  for (const nodeId of nodesToProcess) {\n    const neighbors = getNeighbors(nodeId, neighborDepth, nodeInfo);\n    for (const n of neighbors) focusNodes.add(n);\n  }\n\n  for (const link of links) {\n    const src = getLinkNodeId(link.source);\n    const tgt = getLinkNodeId(link.target);\n    if (focusNodes.has(src) && focusNodes.has(tgt)) {\n      focusLinks.add(link);\n    }\n  }\n\n  return { focusNodes, focusLinks };\n}\n\nexport function getNodeState(\n  nodeId: string,\n  selectedNodes: Set<string>,\n  hoverNode: string | null,\n  focusNodes: Set<string>\n): 'regular' | 'highlighted' | 'lessened' {\n  if (selectedNodes.has(nodeId) || hoverNode === nodeId) return 'highlighted';\n  if (focusNodes.size === 0 || focusNodes.has(nodeId)) return 'regular';\n  return 'lessened';\n}\n\nexport function getLinkState(\n  link: AugmentedLink,\n  focusNodes: Set<string>,\n  focusLinks: Set<AugmentedLink>\n): 'regular' | 'highlighted' | 'lessened' {\n  if (focusNodes.size === 0) return 'regular';\n  const src = getLinkNodeId(link.source);\n  const tgt = getLinkNodeId(link.target);\n  for (const fl of focusLinks) {\n    if (getLinkNodeId(fl.source) === src && getLinkNodeId(fl.target) === tgt) {\n      return 'highlighted';\n    }\n  }\n  return 'lessened';\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/painter.ts",
    "content": "import type { RGBColor } from 'd3-color';\n\ninterface Circle {\n  x: number;\n  y: number;\n  radius: number;\n}\n\ninterface Label {\n  x: number;\n  y: number;\n  text: string;\n  size: number;\n  family: string;\n  color: RGBColor;\n}\n\nexport class Painter {\n  private circlesByColor: Map<RGBColor, Circle[]> = new Map();\n  private bordersByColor: Map<RGBColor, Circle[]> = new Map();\n  private texts: Label[] = [];\n\n  private _addCircle(\n    x: number,\n    y: number,\n    radius: number,\n    color: RGBColor,\n    isBorder = false\n  ): void {\n    if (color.opacity <= 0) return;\n    const target = isBorder ? this.bordersByColor : this.circlesByColor;\n    if (!target.has(color)) target.set(color, []);\n    target.get(color)!.push({ x, y, radius });\n  }\n\n  private _areSameColor(a: RGBColor, b: RGBColor): boolean {\n    return a.r === b.r && a.g === b.g && a.b === b.b && a.opacity === b.opacity;\n  }\n\n  circle(x: number, y: number, radius: number, fill: RGBColor, border: RGBColor): this {\n    this._addCircle(x, y, radius + 0.2, border, true);\n    if (!this._areSameColor(border, fill)) {\n      this._addCircle(x, y, radius, fill);\n    }\n    return this;\n  }\n\n  text(\n    text: string,\n    x: number,\n    y: number,\n    size: number,\n    family: string,\n    color: RGBColor\n  ): this {\n    if (color.opacity > 0) {\n      this.texts.push({ x, y, text, size, family, color });\n    }\n    return this;\n  }\n\n  paint(ctx: CanvasRenderingContext2D): this {\n    // Draw borders first, then fills\n    for (const target of [this.bordersByColor, this.circlesByColor]) {\n      for (const [color, circles] of target.entries()) {\n        ctx.beginPath();\n        ctx.fillStyle = color.toString();\n        for (const c of circles) {\n          ctx.arc(c.x, c.y, c.radius, 0, 2 * Math.PI, false);\n        }\n        ctx.closePath();\n        ctx.fill();\n      }\n      target.clear();\n    }\n\n    // Draw labels\n    ctx.textAlign = 'center';\n    ctx.textBaseline = 'top';\n    for (const label of this.texts) {\n      ctx.font = `${label.size}px ${label.family}`;\n      ctx.fillStyle = label.color.toString();\n      ctx.fillText(label.text, label.x, label.y);\n    }\n    this.texts = [];\n\n    return this;\n  }\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/lib/types.ts",
    "content": "import type { NodeInfo } from '../protocol';\n\nexport interface AugmentedNode extends NodeInfo {\n  neighbors: string[];\n  links: AugmentedLink[];\n}\n\nexport interface AugmentedLink {\n  source: string | AugmentedNode;\n  target: string | AugmentedNode;\n}\n\nexport interface AugmentedGraph {\n  nodeInfo: Record<string, AugmentedNode>;\n  links: AugmentedLink[];\n}\n\nexport interface ResolvedStyle {\n  background: string;\n  fontSize: number;\n  fontFamily: string;\n  lineColor: string;\n  lineWidth: number;\n  particleWidth: number;\n  highlightedForeground: string;\n  node: {\n    note: string;\n    placeholder: string;\n    tag: string;\n    [key: string]: string;\n  };\n  colorMode: 'none' | 'directory';\n}\n\nexport type NodeState = 'regular' | 'highlighted' | 'lessened';\nexport type LinkState = 'regular' | 'highlighted' | 'lessened';\n\nexport interface Forces {\n  collide: number;\n  repel: number;\n  link: number;\n  velocityDecay: number;\n}\n\nexport interface Selection {\n  neighborDepth: number;\n  enableRefocus: boolean;\n  enableZoom: boolean;\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/main.css",
    "content": "* {\n  box-sizing: border-box;\n  margin: 0;\n  padding: 0;\n}\n\nhtml,\nbody,\n#app {\n  width: 100%;\n  height: 100%;\n  overflow: hidden;\n}\n\nfoam-graph {\n  display: block;\n  width: 100%;\n  height: 100%;\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/main.ts",
    "content": "import './foam-graph';\nimport type { ExtensionMessage, WebviewMessage } from './protocol';\nimport type { FoamGraph } from './foam-graph';\n\ndeclare function acquireVsCodeApi(): {\n  postMessage: (msg: WebviewMessage) => void;\n};\n\nconst vscode = (() => {\n  try {\n    return acquireVsCodeApi();\n  } catch {\n    // Not in VS Code — provide a mock for browser-based development\n    return {\n      postMessage: (msg: unknown) => console.log('[mock vscode] postMessage', msg),\n    };\n  }\n})();\n\nconst element = document.createElement('foam-graph') as FoamGraph;\ndocument.getElementById('app')!.appendChild(element);\n\nwindow.addEventListener('message', (event: MessageEvent) => {\n  const message = event.data as ExtensionMessage;\n  switch (message.type) {\n    case 'didUpdateGraphData':\n      element.graphData = message.payload;\n      break;\n    case 'didUpdateStyle':\n      element.graphStyle = message.payload;\n      break;\n    case 'didSelectNote':\n      element.selectNote(message.payload);\n      break;\n  }\n});\n\nelement.addEventListener('node-click', (e: Event) => {\n  vscode.postMessage({\n    type: 'webviewDidSelectNode',\n    payload: (e as CustomEvent).detail,\n  });\n});\n\nwindow.addEventListener('error', (error: ErrorEvent) => {\n  vscode.postMessage({\n    type: 'error',\n    payload: {\n      message: error.message,\n      filename: error.filename,\n      lineno: error.lineno,\n      colno: error.colno,\n      error: error.error,\n    },\n  });\n});\n\nvscode.postMessage({ type: 'webviewDidLoad' });\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/src/protocol.ts",
    "content": "/**\n * Shared message types between the extension host and the graph webview.\n * This file must remain free of VS Code and Node.js imports.\n */\n\nexport type NodeType =\n  | 'note'\n  | 'tag'\n  | 'placeholder'\n  | 'image'\n  | 'attachment'\n  | string;\n\nexport interface NodeInfo {\n  id: string;\n  type: NodeType;\n  title: string;\n  properties: { color?: string; [key: string]: unknown };\n  tags: Array<{ label: string }>;\n}\n\nexport interface GraphData {\n  nodeInfo: Record<string, NodeInfo>;\n  links: Array<{ source: string; target: string }>;\n}\n\nexport interface StyleConfig {\n  background?: string;\n  fontSize?: number;\n  fontFamily?: string;\n  lineColor?: string;\n  lineWidth?: number;\n  particleWidth?: number;\n  highlightedForeground?: string;\n  node?: {\n    note?: string;\n    placeholder?: string;\n    tag?: string;\n    [key: string]: string | undefined;\n  };\n}\n\n/**\n * The payload for the didUpdateStyle message.\n * Matches the shape of the foam.graph.style VS Code configuration object.\n */\nexport interface StylePayload {\n  style?: StyleConfig;\n  colorMode?: 'none' | 'directory';\n}\n\n// Extension → Webview\nexport type ExtensionMessage =\n  | { type: 'didUpdateStyle'; payload: StylePayload }\n  | { type: 'didUpdateGraphData'; payload: GraphData }\n  | { type: 'didSelectNote'; payload: string };\n\n// Webview → Extension\nexport type WebviewMessage =\n  | { type: 'webviewDidLoad' }\n  | { type: 'webviewDidSelectNode'; payload: string }\n  | {\n      type: 'error';\n      payload: {\n        message: string;\n        filename: string;\n        lineno: number;\n        colno: number;\n        error?: unknown;\n      };\n    };\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"ES2022\",\n    \"moduleResolution\": \"bundler\",\n    \"lib\": [\"ES2022\", \"DOM\"],\n    \"strict\": true,\n    \"noUnusedLocals\": false,\n    \"noUnusedParameters\": false,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"experimentalDecorators\": true,\n    \"useDefineForClassFields\": false\n  },\n  \"include\": [\"src/**/*.ts\"],\n  \"exclude\": [\"node_modules\"]\n}\n"
  },
  {
    "path": "packages/foam-vscode/webview-ui/graph/vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    environment: 'happy-dom',\n  },\n});\n"
  },
  {
    "path": "readme.md",
    "content": "<div align=\"center\">\n\n<img src=\"packages/foam-vscode/assets/icon/FOAM_ICON_256.png\" width=\"100\"/>\n\n# Foam\n\n<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->\n\n[![All Contributors](https://img.shields.io/badge/all_contributors-130-orange.svg?style=flat-square)](#contributors-)\n\n<!-- ALL-CONTRIBUTORS-BADGE:END -->\n\n[![Version](https://img.shields.io/visual-studio-marketplace/v/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Downloads](https://img.shields.io/visual-studio-marketplace/d/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs&cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)\n[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)\n[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/foambubble/foam)\n\n</div>\n\n**Foam** is a personal knowledge management and sharing system inspired by [Roam Research](https://roamresearch.com/), built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/).\n\nYou can use **Foam** for organising your research, keeping re-discoverable notes, writing long-form content and, optionally, publishing it to the web.\n\n**Foam** is free, open source, and extremely extensible to suit your personal workflow. You own the information you create with Foam, and you're free to share it, and collaborate on it with anyone you want.\n\n## Features\n\n### Graph Visualization\n\nSee how your notes are connected via a [graph](https://foambubble.github.io/foam/user/features/graph-visualization) with the `Foam: Show Graph` command.\n\n![Graph Visualization](./assets/screenshots/feature-show-graph.gif)\n\n### Link Autocompletion\n\nFoam helps you create the connections between your notes, and your placeholders as well.\n\n![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)\n\n### Sync links on file rename\n\nFoam updates the links to renamed files, so your notes stay consistent.\n\n![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)\n\n### Unique identifiers across directories\n\nFoam supports files with the same name in multiple directories.\nIt will use the minimum identifier required, and even report and help you fix existing ambiguous wikilinks.\n\n![Unique identifier autocompletion](./assets/screenshots/feature-unique-wikilink-completion.gif)\n\n![Wikilink diagnostic](./assets/screenshots/feature-wikilink-diagnostics.gif)\n\n### Link Preview and Navigation\n\n![Link Preview and Navigation](./assets/screenshots/feature-navigation.gif)\n\n### Go to definition, Peek References\n\nSee where a note is being referenced in your knowledge base.\n\n![Go to Definition, Peek References](./assets/screenshots/feature-definition-references.gif)\n\n### Navigation in Preview\n\nNavigate your rendered notes in the VS Code preview panel.\n\n![Navigation in Preview](./assets/screenshots/feature-preview-navigation.gif)\n\n### Note embed\n\nEmbed the content from other notes.\n\n![Note Embed](./assets/screenshots/feature-note-embed.gif)\n\n### Support for sections\n\nFoam supports autocompletion, navigation, embedding and diagnostics for note sections.\nJust use the standard wiki syntax of `[[resource#Section Title]]`.\n\n### Link Alias\n\nFoam supports link aliasing, so you can have a `[[wikilink]]`, or a `[[wikilink|alias]]`.\n\n### Templates\n\nUse [custom templates](https://foambubble.github.io/foam/user/features/templates) to have avoid repetitve work on your notes.\n\n![Templates](./assets/screenshots/feature-templates.gif)\n\n### Backlinks Panel\n\nQuickly check which notes are referencing the currently active note.\nSee for each occurrence the context in which it lives, as well as a preview of the note.\n\n![Backlinks Panel](./assets/screenshots/feature-backlinks-panel.gif)\n\n### Tag Explorer Panel\n\nTag your notes and navigate them with the [Tag Explorer](https://foambubble.github.io/foam/user/features/tags).\nFoam also supports hierarchical tags.\n\n![Tag Explorer Panel](./assets/screenshots/feature-tags-panel.gif)\n\n### Orphans and Placeholder Panels\n\nOrphans are notes that have no inbound nor outbound links.\nPlaceholders are dangling links, or notes without content.\nKeep them under control, and your knowledge base in a better state, by using this panel.\n\n![Orphans and Placeholder Panels](./assets/screenshots/feature-placeholder-orphan-panel.gif)\n\n### Syntax highlight\n\nFoam highlights wikilinks and placeholder differently, to help you visualize your knowledge base.\n\n![Syntax Highlight](./assets/screenshots/feature-syntax-highlight.png)\n\n### Daily note\n\nCreate a journal with [daily notes](https://foambubble.github.io/foam/user/features/daily-notes).\n\n![Daily Note](./assets/screenshots/feature-daily-note.gif)\n\n### Generate references for your wikilinks\n\nCreate markdown [references](https://foambubble.github.io/foam/user/features/link-reference-definitions) for `[[wikilinks]]`, to use your notes in a non-Foam workspace.\nWith references you can also make your notes navigable both in GitHub UI as well as GitHub Pages.\n\n![Generate references](./assets/screenshots/feature-definitions-generation.gif)\n\n### Commands\n\n- Explore your knowledge base with the `Foam: Open Random Note` command\n- Access your daily note with the `Foam: Open Daily Note` command\n- Create a new note with the `Foam: Create New Note` command\n  - This becomes very powerful when combined with [note templates](https://foambubble.github.io/foam/user/features/templates) and the `Foam: Create New Note from Template` command\n- See your workspace as a connected graph with the `Foam: Show Graph` command\n\n## Recipes\n\nPeople use Foam in different ways for different use cases, check out the [recipes](https://foambubble.github.io/foam/user/recipes/recipes) page for inspiration!\n\n## Getting started\n\nWhether you want to build a [Second Brain](https://www.buildingasecondbrain.com/) or a [Zettelkasten](https://zettelkasten.de/posts/overview/), write a book, or just get better at long-term learning, **Foam** can help you organise your thoughts if you follow these simple rules:\n\n1. Create a single **Foam** workspace for all your knowledge and research following the [[Getting started]] guide.\n2. Write your thoughts in markdown documents (I like to call them **Bubbles**, but that might be more than a little twee). These documents should be atomic: Put things that belong together into a single document, and limit its content to that single topic. ([source](https://zettelkasten.de/posts/overview/#principles))\n3. Use Foam's shortcuts and autocompletions to link your thoughts together with `[[wikilinks]]`, and navigate between them to explore your knowledge graph.\n4. Get an overview of your **Foam** workspace using the [[Graph Visualisation]], and discover relationships between your thoughts with the use of [[Backlinking]].\n\nYou can also use our Foam template:\n\n1. Log in on your GitHub account.\n2. [Create a GitHub repository from foam-template](https://github.com/foambubble/foam-template/generate). If you want to keep your thoughts to yourself, remember to set the repository private.\n3. Clone the repository and open it in VS Code.\n4. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one).\n\nThis will also install `Foam`, but if you already have it installed, that's ok, just make sure you're up to date on the latest version.\n\n## Requirements\n\nHigh tolerance for alpha-grade software.\nFoam is still a Work in Progress.\nRest assured it will never lock you in, nor compromise your files, but sometimes some features might break ;)\n\n## Known Issues\n\nSee the [issues](https://github.com/foambubble/foam/issues/) on our GitHub repo ;)\n\n## Release Notes\n\nSee the [CHANGELOG](./packages/foam-vscode/CHANGELOG.md).\n\n## Learn more\n\n**Head over to the 👉[Published version of this Foam workspace](https://foambubble.github.io/foam#whats-in-a-foam)** to see Foam in action and read the rest of the documentation!\n\nQuick links to next documentation sections\n\n- [What's in a Foam?](https://foambubble.github.io/foam#whats-in-a-foam)\n- [Getting started](https://foambubble.github.io/foam#getting-started)\n- [Features](https://foambubble.github.io/foam#features)\n- [Call To Adventure](https://foambubble.github.io/foam#call-to-adventure)\n- [Thanks and attribution](https://foambubble.github.io/foam#thanks-and-attribution)\n\nYou can also browse the [docs folder](https://github.com/foambubble/foam/tree/main/docs).\n\n## License\n\nFoam is licensed under the [MIT license](LICENSE).\n\n[Backlinking]: docs/user/features/backlinking.md 'Backlinking'\n\n## Contribution Guide\n\nSee the [Contribution Guide](https://foambubble.github.io/foam/dev/contribution-guide)\n\n## Code of conduct\n\nSee the [Code of Conduct](https://foambubble.github.io/foam/dev/code-of-conduct)\n\n## Contributors ✨\n\nThanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)):\n\n<!-- ALL-CONTRIBUTORS-LIST:START - Do not remove or modify this section -->\n<!-- prettier-ignore-start -->\n<!-- markdownlint-disable -->\n<table>\n  <tbody>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://jevakallio.dev/\"><img src=\"https://avatars1.githubusercontent.com/u/1203949?v=4?s=60\" width=\"60px;\" alt=\"Jani Eväkallio\"/><br /><sub><b>Jani Eväkallio</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jevakallio\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jevakallio\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://joeprevite.com/\"><img src=\"https://avatars3.githubusercontent.com/u/3806031?v=4?s=60\" width=\"60px;\" alt=\"Joe Previte\"/><br /><sub><b>Joe Previte</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jsjoeio\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jsjoeio\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/riccardoferretti\"><img src=\"https://avatars3.githubusercontent.com/u/457005?v=4?s=60\" width=\"60px;\" alt=\"Riccardo\"/><br /><sub><b>Riccardo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=riccardoferretti\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=riccardoferretti\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://ojanaho.com/\"><img src=\"https://avatars0.githubusercontent.com/u/2180090?v=4?s=60\" width=\"60px;\" alt=\"Janne Ojanaho\"/><br /><sub><b>Janne Ojanaho</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jojanaho\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jojanaho\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://bypaulshen.com/\"><img src=\"https://avatars3.githubusercontent.com/u/2266187?v=4?s=60\" width=\"60px;\" alt=\"Paul Shen\"/><br /><sub><b>Paul Shen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=paulshen\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/coffenbacher\"><img src=\"https://avatars0.githubusercontent.com/u/245867?v=4?s=60\" width=\"60px;\" alt=\"coffenbacher\"/><br /><sub><b>coffenbacher</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=coffenbacher\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://mathieu.dutour.me/\"><img src=\"https://avatars2.githubusercontent.com/u/3254314?v=4?s=60\" width=\"60px;\" alt=\"Mathieu Dutour\"/><br /><sub><b>Mathieu Dutour</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=mathieudutour\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/presidentelect\"><img src=\"https://avatars2.githubusercontent.com/u/1242300?v=4?s=60\" width=\"60px;\" alt=\"Michael Hansen\"/><br /><sub><b>Michael Hansen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=presidentelect\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://klickverbot.at/\"><img src=\"https://avatars1.githubusercontent.com/u/19335?v=4?s=60\" width=\"60px;\" alt=\"David Nadlinger\"/><br /><sub><b>David Nadlinger</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dnadlinger\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://pluckd.co/\"><img src=\"https://avatars2.githubusercontent.com/u/20598571?v=4?s=60\" width=\"60px;\" alt=\"Fernando\"/><br /><sub><b>Fernando</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MrCordeiro\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/jfgonzalez7\"><img src=\"https://avatars3.githubusercontent.com/u/58857736?v=4?s=60\" width=\"60px;\" alt=\"Juan Gonzalez\"/><br /><sub><b>Juan Gonzalez</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jfgonzalez7\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.louiechristie.com/\"><img src=\"https://avatars1.githubusercontent.com/u/6807448?v=4?s=60\" width=\"60px;\" alt=\"Louie Christie\"/><br /><sub><b>Louie Christie</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=louiechristie\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://supersandro.de/\"><img src=\"https://avatars2.githubusercontent.com/u/7258858?v=4?s=60\" width=\"60px;\" alt=\"Sandro\"/><br /><sub><b>Sandro</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=SuperSandro2000\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Skn0tt\"><img src=\"https://avatars1.githubusercontent.com/u/14912729?v=4?s=60\" width=\"60px;\" alt=\"Simon Knott\"/><br /><sub><b>Simon Knott</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Skn0tt\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://styfle.dev/\"><img src=\"https://avatars1.githubusercontent.com/u/229881?v=4?s=60\" width=\"60px;\" alt=\"Steven\"/><br /><sub><b>Steven</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=styfle\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Georift\"><img src=\"https://avatars2.githubusercontent.com/u/859430?v=4?s=60\" width=\"60px;\" alt=\"Tim\"/><br /><sub><b>Tim</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Georift\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/sauravkhdoolia\"><img src=\"https://avatars1.githubusercontent.com/u/34188267?v=4?s=60\" width=\"60px;\" alt=\"Saurav Khdoolia\"/><br /><sub><b>Saurav Khdoolia</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sauravkhdoolia\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://anku.netlify.com/\"><img src=\"https://avatars1.githubusercontent.com/u/22813027?v=4?s=60\" width=\"60px;\" alt=\"Ankit Tiwari\"/><br /><sub><b>Ankit Tiwari</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Documentation\">📖</a> <a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Tests\">⚠️</a> <a href=\"https://github.com/foambubble/foam/commits?author=anku255\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ayushbaweja\"><img src=\"https://avatars1.githubusercontent.com/u/44344063?v=4?s=60\" width=\"60px;\" alt=\"Ayush Baweja\"/><br /><sub><b>Ayush Baweja</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ayushbaweja\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/TaiChi-IO\"><img src=\"https://avatars3.githubusercontent.com/u/65092992?v=4?s=60\" width=\"60px;\" alt=\"TaiChi-IO\"/><br /><sub><b>TaiChi-IO</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=TaiChi-IO\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/juanfrank77\"><img src=\"https://avatars1.githubusercontent.com/u/12146882?v=4?s=60\" width=\"60px;\" alt=\"Juan F Gonzalez \"/><br /><sub><b>Juan F Gonzalez </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=juanfrank77\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://sanketdg.github.io\"><img src=\"https://avatars3.githubusercontent.com/u/8980971?v=4?s=60\" width=\"60px;\" alt=\"Sanket Dasgupta\"/><br /><sub><b>Sanket Dasgupta</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=SanketDG\" title=\"Documentation\">📖</a> <a href=\"https://github.com/foambubble/foam/commits?author=SanketDG\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/nstafie\"><img src=\"https://avatars1.githubusercontent.com/u/10801854?v=4?s=60\" width=\"60px;\" alt=\"Nicholas Stafie\"/><br /><sub><b>Nicholas Stafie</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nstafie\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/francishamel\"><img src=\"https://avatars3.githubusercontent.com/u/36383308?v=4?s=60\" width=\"60px;\" alt=\"Francis Hamel\"/><br /><sub><b>Francis Hamel</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=francishamel\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://digiguru.co.uk\"><img src=\"https://avatars1.githubusercontent.com/u/619436?v=4?s=60\" width=\"60px;\" alt=\"digiguru\"/><br /><sub><b>digiguru</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=digiguru\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=digiguru\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/chirag-singhal\"><img src=\"https://avatars3.githubusercontent.com/u/42653703?v=4?s=60\" width=\"60px;\" alt=\"CHIRAG SINGHAL\"/><br /><sub><b>CHIRAG SINGHAL</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=chirag-singhal\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/lostintangent\"><img src=\"https://avatars3.githubusercontent.com/u/116461?v=4?s=60\" width=\"60px;\" alt=\"Jonathan Carter\"/><br /><sub><b>Jonathan Carter</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=lostintangent\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.synesthesia.co.uk\"><img src=\"https://avatars3.githubusercontent.com/u/181399?v=4?s=60\" width=\"60px;\" alt=\"Julian Elve\"/><br /><sub><b>Julian Elve</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=synesthesia\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/thomaskoppelaar\"><img src=\"https://avatars3.githubusercontent.com/u/36331365?v=4?s=60\" width=\"60px;\" alt=\"Thomas Koppelaar\"/><br /><sub><b>Thomas Koppelaar</b></sub></a><br /><a href=\"#question-thomaskoppelaar\" title=\"Answering Questions\">💬</a> <a href=\"https://github.com/foambubble/foam/commits?author=thomaskoppelaar\" title=\"Code\">💻</a> <a href=\"#userTesting-thomaskoppelaar\" title=\"User Testing\">📓</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.akshaymehra.com\"><img src=\"https://avatars1.githubusercontent.com/u/8671497?v=4?s=60\" width=\"60px;\" alt=\"Akshay\"/><br /><sub><b>Akshay</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MehraAkshay\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://johnlindquist.com\"><img src=\"https://avatars0.githubusercontent.com/u/36073?v=4?s=60\" width=\"60px;\" alt=\"John Lindquist\"/><br /><sub><b>John Lindquist</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=johnlindquist\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ashwin.run/\"><img src=\"https://avatars2.githubusercontent.com/u/1689183?v=4?s=60\" width=\"60px;\" alt=\"Ashwin Ramaswami\"/><br /><sub><b>Ashwin Ramaswami</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=epicfaace\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Klaudioz\"><img src=\"https://avatars1.githubusercontent.com/u/632625?v=4?s=60\" width=\"60px;\" alt=\"Claudio Canales\"/><br /><sub><b>Claudio Canales</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Klaudioz\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/vitaly-pevgonen\"><img src=\"https://avatars0.githubusercontent.com/u/6272738?v=4?s=60\" width=\"60px;\" alt=\"vitaly-pevgonen\"/><br /><sub><b>vitaly-pevgonen</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=vitaly-pevgonen\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://dshemetov.github.io\"><img src=\"https://avatars0.githubusercontent.com/u/1810426?v=4?s=60\" width=\"60px;\" alt=\"Dmitry Shemetov\"/><br /><sub><b>Dmitry Shemetov</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dshemetov\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/hooncp\"><img src=\"https://avatars1.githubusercontent.com/u/48883554?v=4?s=60\" width=\"60px;\" alt=\"hooncp\"/><br /><sub><b>hooncp</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hooncp\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://rt-canada.ca\"><img src=\"https://avatars1.githubusercontent.com/u/13721239?v=4?s=60\" width=\"60px;\" alt=\"Martin Laws\"/><br /><sub><b>Martin Laws</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=martinlaws\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://seanksmith.me\"><img src=\"https://avatars3.githubusercontent.com/u/2085441?v=4?s=60\" width=\"60px;\" alt=\"Sean K Smith\"/><br /><sub><b>Sean K Smith</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sksmith\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/kevin-neely/\"><img src=\"https://avatars1.githubusercontent.com/u/37545028?v=4?s=60\" width=\"60px;\" alt=\"Kevin Neely\"/><br /><sub><b>Kevin Neely</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=kneely\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ariefrahmansyah.dev\"><img src=\"https://avatars3.githubusercontent.com/u/8122852?v=4?s=60\" width=\"60px;\" alt=\"Arief Rahmansyah\"/><br /><sub><b>Arief Rahmansyah</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ariefrahmansyah\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://vhanda.in\"><img src=\"https://avatars2.githubusercontent.com/u/426467?v=4?s=60\" width=\"60px;\" alt=\"Vishesh Handa\"/><br /><sub><b>Vishesh Handa</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=vHanda\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.linkedin.com/in/heroichitesh\"><img src=\"https://avatars3.githubusercontent.com/u/37622734?v=4?s=60\" width=\"60px;\" alt=\"Hitesh Kumar\"/><br /><sub><b>Hitesh Kumar</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=HeroicHitesh\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://spencerwoo.com\"><img src=\"https://avatars2.githubusercontent.com/u/32114380?v=4?s=60\" width=\"60px;\" alt=\"Spencer Woo\"/><br /><sub><b>Spencer Woo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=spencerwooo\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://ingalless.com\"><img src=\"https://avatars3.githubusercontent.com/u/22981941?v=4?s=60\" width=\"60px;\" alt=\"ingalless\"/><br /><sub><b>ingalless</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ingalless\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=ingalless\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://jmg-duarte.github.io\"><img src=\"https://avatars2.githubusercontent.com/u/15343819?v=4?s=60\" width=\"60px;\" alt=\"José Duarte\"/><br /><sub><b>José Duarte</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jmg-duarte\" title=\"Code\">💻</a> <a href=\"https://github.com/foambubble/foam/commits?author=jmg-duarte\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.yenly.wtf\"><img src=\"https://avatars1.githubusercontent.com/u/6759658?v=4?s=60\" width=\"60px;\" alt=\"Yenly\"/><br /><sub><b>Yenly</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=yenly\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.hikerpig.cn\"><img src=\"https://avatars1.githubusercontent.com/u/2259688?v=4?s=60\" width=\"60px;\" alt=\"hikerpig\"/><br /><sub><b>hikerpig</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hikerpig\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://sigfried.org\"><img src=\"https://avatars1.githubusercontent.com/u/1586931?v=4?s=60\" width=\"60px;\" alt=\"Sigfried Gold\"/><br /><sub><b>Sigfried Gold</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Sigfried\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.tristansokol.com\"><img src=\"https://avatars3.githubusercontent.com/u/867661?v=4?s=60\" width=\"60px;\" alt=\"Tristan Sokol\"/><br /><sub><b>Tristan Sokol</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tristansokol\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://umbrellait.com\"><img src=\"https://avatars0.githubusercontent.com/u/49779373?v=4?s=60\" width=\"60px;\" alt=\"Danil Rodin\"/><br /><sub><b>Danil Rodin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=umbrellait-danil-rodin\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/scottjoewilliams/\"><img src=\"https://avatars1.githubusercontent.com/u/2026866?v=4?s=60\" width=\"60px;\" alt=\"Scott Williams\"/><br /><sub><b>Scott Williams</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=scott-joe\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://jackiexiao.github.io/blog\"><img src=\"https://avatars2.githubusercontent.com/u/18050469?v=4?s=60\" width=\"60px;\" alt=\"jackiexiao\"/><br /><sub><b>jackiexiao</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Jackiexiao\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://generativist.substack.com/\"><img src=\"https://avatars3.githubusercontent.com/u/78835?v=4?s=60\" width=\"60px;\" alt=\"John B Nelson\"/><br /><sub><b>John B Nelson</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jbn\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/asifm\"><img src=\"https://avatars2.githubusercontent.com/u/3958387?v=4?s=60\" width=\"60px;\" alt=\"Asif Mehedi\"/><br /><sub><b>Asif Mehedi</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=asifm\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/litanlitudan\"><img src=\"https://avatars2.githubusercontent.com/u/4970420?v=4?s=60\" width=\"60px;\" alt=\"Tan Li\"/><br /><sub><b>Tan Li</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=litanlitudan\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://shaunagordon.com\"><img src=\"https://avatars1.githubusercontent.com/u/579361?v=4?s=60\" width=\"60px;\" alt=\"Shauna Gordon\"/><br /><sub><b>Shauna Gordon</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ShaunaGordon\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://mcluck.tech\"><img src=\"https://avatars1.githubusercontent.com/u/1753801?v=4?s=60\" width=\"60px;\" alt=\"Mike Cluck\"/><br /><sub><b>Mike Cluck</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MCluck90\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://brandonpugh.com\"><img src=\"https://avatars1.githubusercontent.com/u/684781?v=4?s=60\" width=\"60px;\" alt=\"Brandon Pugh\"/><br /><sub><b>Brandon Pugh</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bpugh\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://max.davitt.me\"><img src=\"https://avatars1.githubusercontent.com/u/27709025?v=4?s=60\" width=\"60px;\" alt=\"Max Davitt\"/><br /><sub><b>Max Davitt</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=themaxdavitt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://briananglin.me\"><img src=\"https://avatars3.githubusercontent.com/u/2637602?v=4?s=60\" width=\"60px;\" alt=\"Brian Anglin\"/><br /><sub><b>Brian Anglin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=anglinb\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://deft.work\"><img src=\"https://avatars1.githubusercontent.com/u/1455507?v=4?s=60\" width=\"60px;\" alt=\"elswork\"/><br /><sub><b>elswork</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=elswork\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://leonh.fr/\"><img src=\"https://avatars.githubusercontent.com/u/19996318?v=4?s=60\" width=\"60px;\" alt=\"léon h\"/><br /><sub><b>léon h</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=leonhfr\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://nygaard.site\"><img src=\"https://avatars.githubusercontent.com/u/4606342?v=4?s=60\" width=\"60px;\" alt=\"Nikhil Nygaard\"/><br /><sub><b>Nikhil Nygaard</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=njnygaard\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.nitwit.se\"><img src=\"https://avatars.githubusercontent.com/u/1382124?v=4?s=60\" width=\"60px;\" alt=\"Mark Dixon\"/><br /><sub><b>Mark Dixon</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nitwit-se\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/joeltjames\"><img src=\"https://avatars.githubusercontent.com/u/3732400?v=4?s=60\" width=\"60px;\" alt=\"Joel James\"/><br /><sub><b>Joel James</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=joeltjames\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.ryo33.com\"><img src=\"https://avatars.githubusercontent.com/u/8780513?v=4?s=60\" width=\"60px;\" alt=\"Hashiguchi Ryo\"/><br /><sub><b>Hashiguchi Ryo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ryo33\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://movermeyer.com\"><img src=\"https://avatars.githubusercontent.com/u/1459385?v=4?s=60\" width=\"60px;\" alt=\"Michael Overmeyer\"/><br /><sub><b>Michael Overmeyer</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=movermeyer\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/derrickqin\"><img src=\"https://avatars.githubusercontent.com/u/3038111?v=4?s=60\" width=\"60px;\" alt=\"Derrick Qin\"/><br /><sub><b>Derrick Qin</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=derrickqin\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.linkedin.com/in/zomars/\"><img src=\"https://avatars.githubusercontent.com/u/3504472?v=4?s=60\" width=\"60px;\" alt=\"Omar López\"/><br /><sub><b>Omar López</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=zomars\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://robincn.com\"><img src=\"https://avatars.githubusercontent.com/u/1583193?v=4?s=60\" width=\"60px;\" alt=\"Robin King\"/><br /><sub><b>Robin King</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=RobinKing\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://twitter.com/deegovee\"><img src=\"https://avatars.githubusercontent.com/u/4730170?v=4?s=60\" width=\"60px;\" alt=\"Dheepak \"/><br /><sub><b>Dheepak </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dheepakg\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/daniel-vera-g\"><img src=\"https://avatars.githubusercontent.com/u/28257108?v=4?s=60\" width=\"60px;\" alt=\"Daniel VG\"/><br /><sub><b>Daniel VG</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=daniel-vera-g\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Barabazs\"><img src=\"https://avatars.githubusercontent.com/u/31799121?v=4?s=60\" width=\"60px;\" alt=\"Barabas\"/><br /><sub><b>Barabas</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Barabazs\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://enginveske@gmail.com\"><img src=\"https://avatars.githubusercontent.com/u/43685404?v=4?s=60\" width=\"60px;\" alt=\"Engincan VESKE\"/><br /><sub><b>Engincan VESKE</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=EngincanV\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.paulderaaij.nl\"><img src=\"https://avatars.githubusercontent.com/u/495374?v=4?s=60\" width=\"60px;\" alt=\"Paul de Raaij\"/><br /><sub><b>Paul de Raaij</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=pderaaij\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bronson\"><img src=\"https://avatars.githubusercontent.com/u/1776?v=4?s=60\" width=\"60px;\" alt=\"Scott Bronson\"/><br /><sub><b>Scott Bronson</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bronson\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://rafaelriedel.de\"><img src=\"https://avatars.githubusercontent.com/u/41793?v=4?s=60\" width=\"60px;\" alt=\"Rafael Riedel\"/><br /><sub><b>Rafael Riedel</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=rafo\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Pearcekieser\"><img src=\"https://avatars.githubusercontent.com/u/5055971?v=4?s=60\" width=\"60px;\" alt=\"Pearcekieser\"/><br /><sub><b>Pearcekieser</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Pearcekieser\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/theowenyoung\"><img src=\"https://avatars.githubusercontent.com/u/62473795?v=4?s=60\" width=\"60px;\" alt=\"Owen Young\"/><br /><sub><b>Owen Young</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=theowenyoung\" title=\"Documentation\">📖</a> <a href=\"#content-theowenyoung\" title=\"Content\">🖋</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.prashu.com\"><img src=\"https://avatars.githubusercontent.com/u/476729?v=4?s=60\" width=\"60px;\" alt=\"Prashanth Subrahmanyam\"/><br /><sub><b>Prashanth Subrahmanyam</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ksprashu\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/JonasSprenger\"><img src=\"https://avatars.githubusercontent.com/u/25108895?v=4?s=60\" width=\"60px;\" alt=\"Jonas SPRENGER\"/><br /><sub><b>Jonas SPRENGER</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=JonasSprenger\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Laptop765\"><img src=\"https://avatars.githubusercontent.com/u/1468359?v=4?s=60\" width=\"60px;\" alt=\"Paul\"/><br /><sub><b>Paul</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Laptop765\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://bandism.net/\"><img src=\"https://avatars.githubusercontent.com/u/22633385?v=4?s=60\" width=\"60px;\" alt=\"Ikko Ashimine\"/><br /><sub><b>Ikko Ashimine</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=eltociear\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/memeplex\"><img src=\"https://avatars.githubusercontent.com/u/2845433?v=4?s=60\" width=\"60px;\" alt=\"memeplex\"/><br /><sub><b>memeplex</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=memeplex\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/AndreiD049\"><img src=\"https://avatars.githubusercontent.com/u/52671223?v=4?s=60\" width=\"60px;\" alt=\"AndreiD049\"/><br /><sub><b>AndreiD049</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=AndreiD049\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/iam-yan\"><img src=\"https://avatars.githubusercontent.com/u/48427014?v=4?s=60\" width=\"60px;\" alt=\"Yan\"/><br /><sub><b>Yan</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=iam-yan\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://WikiEducator.org/User:JimTittsler\"><img src=\"https://avatars.githubusercontent.com/u/180326?v=4?s=60\" width=\"60px;\" alt=\"Jim Tittsler\"/><br /><sub><b>Jim Tittsler</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jimt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://malcolmmielle.wordpress.com/\"><img src=\"https://avatars.githubusercontent.com/u/4457840?v=4?s=60\" width=\"60px;\" alt=\"Malcolm Mielle\"/><br /><sub><b>Malcolm Mielle</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MalcolmMielle\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://snippets.page/\"><img src=\"https://avatars.githubusercontent.com/u/74916913?v=4?s=60\" width=\"60px;\" alt=\"Veesar\"/><br /><sub><b>Veesar</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=veesar\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/bentongxyz\"><img src=\"https://avatars.githubusercontent.com/u/60358804?v=4?s=60\" width=\"60px;\" alt=\"bentongxyz\"/><br /><sub><b>bentongxyz</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=bentongxyz\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://brianjdevries.com\"><img src=\"https://avatars.githubusercontent.com/u/42778030?v=4?s=60\" width=\"60px;\" alt=\"Brian DeVries\"/><br /><sub><b>Brian DeVries</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=techCarpenter\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://Cliffordfajardo.com\"><img src=\"https://avatars.githubusercontent.com/u/6743796?v=4?s=60\" width=\"60px;\" alt=\"Clifford Fajardo \"/><br /><sub><b>Clifford Fajardo </b></sub></a><br /><a href=\"#tool-cliffordfajardo\" title=\"Tools\">🔧</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://cu-dev.ca\"><img src=\"https://avatars.githubusercontent.com/u/6589365?v=4?s=60\" width=\"60px;\" alt=\"Chris Usick\"/><br /><sub><b>Chris Usick</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=chrisUsick\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/josephdecock\"><img src=\"https://avatars.githubusercontent.com/u/1145533?v=4?s=60\" width=\"60px;\" alt=\"Joe DeCock\"/><br /><sub><b>Joe DeCock</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=josephdecock\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.drewtyler.com\"><img src=\"https://avatars.githubusercontent.com/u/5640816?v=4?s=60\" width=\"60px;\" alt=\"Drew Tyler\"/><br /><sub><b>Drew Tyler</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=drewtyler\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Lauviah0622\"><img src=\"https://avatars.githubusercontent.com/u/43416399?v=4?s=60\" width=\"60px;\" alt=\"Lauviah0622\"/><br /><sub><b>Lauviah0622</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Lauviah0622\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.elastic.co/elastic-agent\"><img src=\"https://avatars.githubusercontent.com/u/1813008?v=4?s=60\" width=\"60px;\" alt=\"Josh Dover\"/><br /><sub><b>Josh Dover</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=joshdover\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://phelm.co.uk\"><img src=\"https://avatars.githubusercontent.com/u/4057948?v=4?s=60\" width=\"60px;\" alt=\"Phil Helm\"/><br /><sub><b>Phil Helm</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=phelma\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/lingyv-li\"><img src=\"https://avatars.githubusercontent.com/u/8937944?v=4?s=60\" width=\"60px;\" alt=\"Larry Li\"/><br /><sub><b>Larry Li</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=lingyv-li\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/infogulch\"><img src=\"https://avatars.githubusercontent.com/u/133882?v=4?s=60\" width=\"60px;\" alt=\"Joe Taber\"/><br /><sub><b>Joe Taber</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=infogulch\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://www.readingsnail.pe.kr\"><img src=\"https://avatars.githubusercontent.com/u/1904967?v=4?s=60\" width=\"60px;\" alt=\"Woosuk Park\"/><br /><sub><b>Woosuk Park</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=readingsnail\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.dmurph.com\"><img src=\"https://avatars.githubusercontent.com/u/294026?v=4?s=60\" width=\"60px;\" alt=\"Daniel Murphy\"/><br /><sub><b>Daniel Murphy</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dmurph\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Dominic-DallOsto\"><img src=\"https://avatars.githubusercontent.com/u/26859884?v=4?s=60\" width=\"60px;\" alt=\"Dominic D\"/><br /><sub><b>Dominic D</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Dominic-DallOsto\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://elgirafo.xyz\"><img src=\"https://avatars.githubusercontent.com/u/80516439?v=4?s=60\" width=\"60px;\" alt=\"luca\"/><br /><sub><b>luca</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=elgirafo\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Lloyd-Jackman-UKPL\"><img src=\"https://avatars.githubusercontent.com/u/55206370?v=4?s=60\" width=\"60px;\" alt=\"Lloyd Jackman\"/><br /><sub><b>Lloyd Jackman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Lloyd-Jackman-UKPL\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://sn3akiwhizper.github.io\"><img src=\"https://avatars.githubusercontent.com/u/102705294?v=4?s=60\" width=\"60px;\" alt=\"sn3akiwhizper\"/><br /><sub><b>sn3akiwhizper</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=sn3akiwhizper\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://jonathanpberger.com/\"><img src=\"https://avatars.githubusercontent.com/u/41085?v=4?s=60\" width=\"60px;\" alt=\"jonathan berger\"/><br /><sub><b>jonathan berger</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jonathanpberger\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/badsketch\"><img src=\"https://avatars.githubusercontent.com/u/8953212?v=4?s=60\" width=\"60px;\" alt=\"Daniel Wang\"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=badsketch\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://yongliangliu.com\"><img src=\"https://avatars.githubusercontent.com/u/41845017?v=4?s=60\" width=\"60px;\" alt=\"Liu YongLiang\"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tlylt\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://scottakerman.com\"><img src=\"https://avatars.githubusercontent.com/u/15224439?v=4?s=60\" width=\"60px;\" alt=\"Scott Akerman\"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Skakerman\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.jim-graham.net/\"><img src=\"https://avatars.githubusercontent.com/u/430293?v=4?s=60\" width=\"60px;\" alt=\"Jim Graham\"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=jimgraham\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://t.me/littlepoint\"><img src=\"https://avatars.githubusercontent.com/u/7611700?v=4?s=60\" width=\"60px;\" alt=\"Zhizhen He\"/><br /><sub><b>Zhizhen He</b></sub></a><br /><a href=\"#tool-hezhizhen\" title=\"Tools\">🔧</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://amnesiak.org/me\"><img src=\"https://avatars.githubusercontent.com/u/952059?v=4?s=60\" width=\"60px;\" alt=\"Tony Cheneau\"/><br /><sub><b>Tony Cheneau</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=tcheneau\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/nicholas-l\"><img src=\"https://avatars.githubusercontent.com/u/12977174?v=4?s=60\" width=\"60px;\" alt=\"Nicholas Latham\"/><br /><sub><b>Nicholas Latham</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=nicholas-l\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://thara.dev\"><img src=\"https://avatars.githubusercontent.com/u/1532891?v=4?s=60\" width=\"60px;\" alt=\"Tomochika Hara\"/><br /><sub><b>Tomochika Hara</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=thara\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/dcarosone\"><img src=\"https://avatars.githubusercontent.com/u/11495017?v=4?s=60\" width=\"60px;\" alt=\"Daniel Carosone\"/><br /><sub><b>Daniel Carosone</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=dcarosone\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/MABruni\"><img src=\"https://avatars.githubusercontent.com/u/100445384?v=4?s=60\" width=\"60px;\" alt=\"Miguel Angel Bruni Montero\"/><br /><sub><b>Miguel Angel Bruni Montero</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=MABruni\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Walshkev\"><img src=\"https://avatars.githubusercontent.com/u/77123083?v=4?s=60\" width=\"60px;\" alt=\"Kevin Walsh \"/><br /><sub><b>Kevin Walsh </b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Walshkev\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://hereistheusername.github.io/\"><img src=\"https://avatars.githubusercontent.com/u/33437051?v=4?s=60\" width=\"60px;\" alt=\"Xinglan Liu\"/><br /><sub><b>Xinglan Liu</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=hereistheusername\" title=\"Code\">💻</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://www.hegghammer.com\"><img src=\"https://avatars.githubusercontent.com/u/64712218?v=4?s=60\" width=\"60px;\" alt=\"Thomas Hegghammer\"/><br /><sub><b>Thomas Hegghammer</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Hegghammer\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/PiotrAleksander\"><img src=\"https://avatars.githubusercontent.com/u/6314591?v=4?s=60\" width=\"60px;\" alt=\"Piotr Mrzygłosz\"/><br /><sub><b>Piotr Mrzygłosz</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=PiotrAleksander\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://schaver.com/\"><img src=\"https://avatars.githubusercontent.com/u/7584?v=4?s=60\" width=\"60px;\" alt=\"Mark Schaver\"/><br /><sub><b>Mark Schaver</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=markschaver\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/n8layman\"><img src=\"https://avatars.githubusercontent.com/u/25353944?v=4?s=60\" width=\"60px;\" alt=\"Nathan Layman\"/><br /><sub><b>Nathan Layman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=n8layman\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/emmanuel-ferdman\"><img src=\"https://avatars.githubusercontent.com/u/35470921?v=4?s=60\" width=\"60px;\" alt=\"Emmanuel Ferdman\"/><br /><sub><b>Emmanuel Ferdman</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=emmanuel-ferdman\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/Tenormis\"><img src=\"https://avatars.githubusercontent.com/u/61572102?v=4?s=60\" width=\"60px;\" alt=\"Tenormis\"/><br /><sub><b>Tenormis</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=Tenormis\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"http://djon.es/blog\"><img src=\"https://avatars.githubusercontent.com/u/225052?v=4?s=60\" width=\"60px;\" alt=\"David Jones\"/><br /><sub><b>David Jones</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=djplaner\" title=\"Documentation\">📖</a></td>\n    </tr>\n    <tr>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/s-jacob-powell\"><img src=\"https://avatars.githubusercontent.com/u/109111499?v=4?s=60\" width=\"60px;\" alt=\"S. Jacob Powell\"/><br /><sub><b>S. Jacob Powell</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=s-jacob-powell\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/figdavi\"><img src=\"https://avatars.githubusercontent.com/u/99026991?v=4?s=60\" width=\"60px;\" alt=\"Davi Figueiredo\"/><br /><sub><b>Davi Figueiredo</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=figdavi\" title=\"Documentation\">📖</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/ChThH\"><img src=\"https://avatars.githubusercontent.com/u/9499483?v=4?s=60\" width=\"60px;\" alt=\"CT Hall\"/><br /><sub><b>CT Hall</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=ChThH\" title=\"Code\">💻</a></td>\n      <td align=\"center\" valign=\"top\" width=\"14.28%\"><a href=\"https://github.com/meestahp\"><img src=\"https://avatars.githubusercontent.com/u/177708514?v=4?s=60\" width=\"60px;\" alt=\"meestahp\"/><br /><sub><b>meestahp</b></sub></a><br /><a href=\"https://github.com/foambubble/foam/commits?author=meestahp\" title=\"Code\">💻</a></td>\n    </tr>\n  </tbody>\n</table>\n\n<!-- markdownlint-restore -->\n<!-- prettier-ignore-end -->\n\n<!-- ALL-CONTRIBUTORS-LIST:END -->\n\nThis project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!\n\n[Backlinking]: docs/user/features/backlinking.md 'Backlinks'\n[//begin]: # 'Autogenerated link references for markdown compatibility'\n[Backlinking]: docs/user/features/backlinking.md 'Backlinks'\n[//end]: # 'Autogenerated link references'\n"
  },
  {
    "path": "tsconfig.base.json",
    "content": "{\n  \"include\": [\"./packages/*/src\"],\n  \"compilerOptions\": {\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"baseUrl\": \".\",\n    \"module\": \"commonjs\",\n    \"target\": \"ES2019\",\n    \"paths\": {\n      \"foam-core\": [\"./packages/foam-core/src\"],\n      \"foam-vscode\": [\"./packages/foam-vscode/src\"]\n    }\n  }\n}\n"
  },
  {
    "path": "typos.toml",
    "content": "# See https://github.com/crate-ci/typos/blob/master/docs/reference.md to configure typos\n[default.extend-words]\nons = \"ons\" # add-ons\npallette = \"pallette\"\n[files]\nextend-exclude = [\"CHANGELOG.md\", \"d3.v6.min.js\", \"force-graph.1.49.5.min.js\", \"dat.gui.min.js\"]\n"
  }
]