Compare commits

...

49 Commits

Author SHA1 Message Date
Riccardo
cf70c97fd7 add note embeddings and semantic similarity search (#1560)
* First implementation of embedding feature

* Improved caching

* Progress on embedding index build

* Added cancellation option, and updating embeds continuously

* Added "Related Notes" panel, plus various tweaks

* Added task deduplicator so that notes analysis command has only one running instance

* Added progress/cancel features to vscode mock

* Tests

* refactored code into `ai` module

* Added `foam.experimental.ai` feature flag, with AI features disabled by default

* `waitForNoteInFoamWorkspace` now fails on timeout

* Added watcher in VS Code mock, and updated related test
2025-12-15 13:22:19 +01:00
Riccardo
5d8b9756a9 Fix #1558 prevent excessive blank lines in link reference definitions (#1561)
* Changed replacement approach

* Changed tests to check results instead of edits

* More tests
2025-12-15 13:15:15 +01:00
Riccardo Ferretti
f63b7b2c9b Added prepare-pr command for Claude 2025-12-12 16:21:26 +01:00
Riccardo Ferretti
875fde8a45 Fix #1451 - highlight alias in wikilinks with it 2025-12-10 14:53:47 +01:00
meestahp
5ef292382e Added appearance panel in graph webview (#1555) 2025-12-04 11:47:11 +01:00
meestahp
0fa0176e1b [Graph-] Added forces panel (#1554) 2025-12-04 11:45:47 +01:00
meestahp
d312eca3d1 [Graph-] Selection Panel (#1556)
* [Graph-] Added toggles to disable zoom & refocus movement on note selection
[Graph-] Added neighbour depth slider & refocus speed slider

* Refactored & updated the action of the refocusSpeedSlider into refocusDurationSlider to better reflect the underlying mechanism.

* Refactored the naming and action of the refocus & zoom checkboxes to avoid double negatives

* Removed refocus/Speed/Duration slider

* Added comment to graph.css detailing reasons for removal of custom style rules for UI controls

* Reverted(removed) null check added in dataviz.ts to keep focus on feature being implemented
2025-12-04 11:43:10 +01:00
Brian
bff39be923 Fix typo in sync-notes.md (#1553) 2025-11-24 14:34:20 +01:00
Riccardo Ferretti
52c16b15df v0.29.1 2025-11-19 12:53:57 +01:00
Riccardo Ferretti
f2a5c6c45a Preparation for release 2025-11-19 12:53:32 +01:00
Riccardo Ferretti
6c354a13d8 Updated badges in README 2025-11-19 12:47:57 +01:00
Riccardo
8826a2b358 Update CNAME 2025-11-19 11:42:14 +01:00
Riccardo
761eeb7336 Create CNAME 2025-11-17 12:50:54 +01:00
Riccardo Ferretti
6d9a8305ce lint 2025-11-13 15:41:26 +01:00
Riccardo
4ee065ff9a Fix #1544 - add support for wikilink with aliases within tables (#1552) 2025-11-13 15:03:48 +01:00
Riccardo
df3cd90ce7 Fix #1536 - add support for emoji variants in tags (#1549) 2025-11-07 17:10:45 +01:00
Riccardo
0af6c4de15 Fix #1422 - Added include patterns for Foam notes (#1550)
Implement `foam.files.include` configuration to allow users to specify which files/directories Foam should include.
Also migrating `foam.files.ignore` to `foam.files.exclude`

Fixes #1422
2025-11-07 17:09:52 +01:00
Riccardo Ferretti
3d49146087 Fixed test 2025-11-07 15:52:18 +01:00
allcontributors[bot]
8cb2246278 add meestahp as a contributor for code (#1547)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-11-07 12:24:50 +01:00
meestahp
72890d33cb Graph: Added option to show graph on startup (#1542) 2025-11-07 12:24:11 +01:00
Riccardo
70b11a921a Fix #1545 - don't treat text in single brackets as links/placeholders if missing a ref (#1546) 2025-11-06 18:59:39 +01:00
Riccardo Ferretti
f457b14ec0 Fix #1400 - load graph in code server 2025-11-06 17:13:55 +01:00
Riccardo Ferretti
bbb4801486 Minor tweak in graph panel creation 2025-10-29 17:31:51 +01:00
Riccardo Ferretti
80b1537324 v0.29.0 2025-10-29 17:21:41 +01:00
Riccardo Ferretti
007315c3a1 lint 2025-10-29 17:21:23 +01:00
Riccardo Ferretti
ec1750d5a6 Preparation for release 2025-10-29 17:19:22 +01:00
Riccardo Ferretti
5480c65a48 Fix #1540 - reuse existing graph panel if present 2025-10-29 17:15:37 +01:00
Riccardo
7afa286ea5 Improved support for link references (#1531)
* Support for links with references

* wikilink also use references

* Removed [[//begin]] and [[//end]] definitions from code and documentation
2025-10-29 16:38:18 +01:00
Riccardo Ferretti
4a7c2d9de2 Fixed links in documentation 2025-10-16 16:53:08 +02:00
Riccardo Ferretti
1f6b2abce2 improved update-docs script 2025-10-16 16:27:24 +02:00
mxxun
5f017ee4ea Unsanitize characters permissible in Windows paths (#1533) 2025-10-16 12:10:42 +02:00
CT Hall
61032668be Added FOAM_DATE_WEEK_YEAR template variable (#1532)
* Updated resolver to include FOAM_DATE_WEEK_YEAR
* Updated docs
2025-10-16 12:09:43 +02:00
Riccardo
9192cefc7c Improved tag search (fix #1516) (#1530) 2025-10-03 16:40:21 +02:00
Riccardo Ferretti
2f966276b5 v0.28.3 2025-10-03 13:24:00 +02:00
Riccardo Ferretti
145970a6cb Preparation for release 2025-10-03 13:23:39 +02:00
Riccardo
54a6ffdf01 Change sanitized characters to support Windows paths (#1529)
Fixed #1526
2025-10-03 13:21:08 +02:00
Riccardo
40740db416 Add Link Conversion Commands and Auto-Convert on Completion (#1525)
* Added setting to automatically convert wikilinks into md links on insertion/completion
* Improved Position mock and added mock for `extensions` namespace

Fixes #1464
2025-10-02 10:48:47 +02:00
Riccardo Ferretti
145653ec85 Reintroduced minification option 2025-10-01 17:55:52 +02:00
Riccardo Ferretti
503b486179 v0.28.2 2025-10-01 16:01:26 +02:00
Riccardo Ferretti
a36d39acf8 Preparation for release 2025-10-01 16:00:49 +02:00
Riccardo Ferretti
fb92790a0a Define global in web extension build (fixes #1523) 2025-10-01 15:58:35 +02:00
Riccardo Ferretti
dcb951004a test:unit task now includes mock-friendly .spec.ts tests 2025-09-25 23:38:20 +02:00
Riccardo Ferretti
3b5906a1cf v0.28.1 2025-09-25 23:32:29 +02:00
Riccardo Ferretti
dc541dea2a Preparation for next release 2025-09-25 23:32:10 +02:00
Riccardo Ferretti
eb908cb689 added test instructions to CLAUDE 2025-09-25 23:27:57 +02:00
Riccardo
967ff18d8d Sanitize filepath in template before note creation (#1520)
fixes #1216
2025-09-25 17:42:44 +02:00
Riccardo
89298b9652 Use identifier case to further disambiguate notes (#1519)
Fixes #1303
2025-09-25 17:29:42 +02:00
Tenormis
e1694f298b Remove duplicate links between nodes (#1511)
Co-authored-by: tenormis <tenormis@mars.com>
2025-09-25 13:02:24 +02:00
allcontributors[bot]
61961f0c1d add ChThH as a contributor for code (#1515)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-09-24 12:30:44 +02:00
115 changed files with 6436 additions and 799 deletions

View File

@@ -1175,6 +1175,24 @@
"contributions": [
"doc"
]
},
{
"login": "ChThH",
"name": "CT Hall",
"avatar_url": "https://avatars.githubusercontent.com/u/9499483?v=4",
"profile": "https://github.com/ChThH",
"contributions": [
"code"
]
},
{
"login": "meestahp",
"name": "meestahp",
"avatar_url": "https://avatars.githubusercontent.com/u/177708514?v=4",
"profile": "https://github.com/meestahp",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

View File

@@ -0,0 +1,59 @@
# Prepare PR Command
Analyze the current branch changes and generate:
- a PR title
- a PR description
- considerations for the developer before pushing the PR
Output the title and description ready to paste into GitHub.
## PR TITLE
Use format: `type(scope): description`
- type: feat/fix/refactor/perf/docs/chore
- Keep under 72 characters
- Be specific but brief
## PR DESCRIPTION
It should have these sections (use a paragraph per section, no need to title them).
CONSTRAINTS:
- 100-200 words total
- No file names or "updated X file" statements
- Active voice
- No filler or pleasantries
- Focus on WHAT and WHY, not HOW
### What Changed
List 2-4 changes grouped by DOMAIN, not files. Focus on:
- User-facing changes
- Architectural shifts
- API changes
Skip trivial updates (formatting, minor refactors).
### Why
One sentence explaining motivation (skip if obvious from title).
### Critical Notes
ONLY include if relevant:
- Breaking changes
- Performance impact
- Security implications
- New dependencies
- Required config/env changes
- Database migrations
If no critical notes exist, omit this section.
## Considerations
Run the `yarn lint` command and report any failures.
Also analize the changeset, and act as a PR reviewer to provide comments about the changes.

View File

@@ -34,7 +34,7 @@ jobs:
# Strip autogenerated wikileaks references because
# they are not an appropriate default user experience.
(cd foam-template/docs; sed -i '/\[\/\/begin\]/,/\[\/\/end\]/d' $(find . -type f -name \*.md))
(cd foam-template/docs; find . -type f -name '*.md' -exec sed -i '/\[\/\/begin\]/,/\[\/\/end\]/d' {} +)
# Set the commit message format
echo "message=Docs sync @ $(cd foam; git log --pretty='format:%h %s')" >> $GITHUB_OUTPUT

View File

@@ -25,7 +25,7 @@
"editor.formatOnSaveMode": "file",
"editor.defaultFormatter": "esbenp.prettier-vscode",
"jest.rootPath": "packages/foam-vscode",
"jest.jestCommandLine": "yarn test:unit-with-specs",
"jest.jestCommandLine": "yarn test:unit",
"gitdoc.enabled": false,
"search.mode": "reuseEditor",
"[typescript]": {

View File

@@ -25,22 +25,22 @@ All the following commands are to be executed from the `packages/foam-vscode` di
### Testing
- `yarn test` - Run all tests (unit + integration)
- `yarn test:unit-with-specs` - Run only unit tests (\*.test.ts files and the .spec.ts files marked a vscode-mock friendly)
- `yarn test:unit` - Run unit tests (\*.test.ts files and the .spec.ts files marked a vscode-mock friendly)
- `yarn test:e2e` - Run only integration tests (\*.spec.ts files)
- `yarn lint` - Run linting
- `yarn test-reset-workspace` to clean test workspace
Unit tests run in Node.js environment using Jest
Integration tests require VS Code extension host
When 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.
Unit 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.
There 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 */`.
- If you are interested in a test inside a `*.test.ts` file, run `yarn test:unit`
- If you are interested in a test inside a `*.spec.ts` file that starts with `/* @unit-ready */` run `yarn test:unit-with-specs`
- 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`
- If you are interested in a test inside a `*.spec.ts` file that does not include `/* @unit-ready */` run `yarn test`
While in development we mostly want to use `yarn test:unit-with-specs`.
While in development we mostly want to use `yarn test:unit`.
When 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.
When 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.

1
docs/CNAME Normal file
View File

@@ -0,0 +1 @@
foamnotes.com

View File

@@ -12,6 +12,4 @@ Here are a few specific constraints, mainly because our tooling is a bit fragmen
- **In addition to normal Markdown Links syntax you can use `[[MediaWiki]]` links.** See [[wikilinks]] for more details.
- **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.
[//begin]: # 'Autogenerated link references for markdown compatibility'
[wikilinks]: ../user/features/wikilinks.md 'Wikilinks'
[//end]: # 'Autogenerated link references'

View File

@@ -49,8 +49,8 @@ This dual-environment capability allows us to:
### Available Commands
- **`yarn test:unit`**: Runs only `.test.ts` files (no VS Code dependencies)
- **`yarn test:unit-with-specs`**: Runs `.test.ts` + `@unit-ready` marked `.spec.ts` files using mocks
- **`yarn test:unit`**: Runs `.test.ts` files (no VS Code dependencies) + `@unit-ready` marked `.spec.ts` files using mocks
- **`yarn test:unit-without-specs`**: Runs only `.test.ts` files
- **`yarn test:e2e`**: Runs all `.spec.ts` files in full VS Code extension host
- **`yarn test`**: Runs both unit and e2e test suites sequentially

View File

@@ -278,6 +278,8 @@ Foam is an evolving project and we welcome contributions:
<tr>
<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>
<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>
<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>
<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>
</tr>
</tbody>
</table>
@@ -295,10 +297,8 @@ Foam builds on [Visual Studio Code](https://code.visualstudio.com/), [GitHub](ht
Foam is licensed under the [MIT license](LICENSE.txt).
[//begin]: # 'Autogenerated link references for markdown compatibility'
[recommended-extensions]: user/getting-started/recommended-extensions.md 'Recommended Extensions'
[recipes]: user/recipes/recipes.md 'Recipes'
[frequently-asked-questions]: user/frequently-asked-questions.md 'Frequently Asked Questions'
[principles]: principles.md 'Principles'
[contribution-guide]: dev/contribution-guide.md 'Contribution Guide'
[//end]: # 'Autogenerated link references'

View File

@@ -1,6 +0,0 @@
- command to bootstrap workspace
- make all extensions ON for attachments by default
- improve settings description
- add deprecation to daily note settings in package.json
- JS filteres and hooks
- plugins (compatibility with Obsidian?)

View File

@@ -71,3 +71,25 @@ Examples:
}
}
```
## Link Conversion Commands
Foam provides commands to convert between wikilink and markdown link formats.
### foam-vscode.convert-wikilink-to-mdlink
Converts a wikilink at the cursor position to markdown link format with a relative path.
Example: `[[my-note]]``[My Note](../path/to/my-note.md)`
### foam-vscode.convert-mdlink-to-wikilink
Converts a markdown link at the cursor position to wikilink format.
Example: `[My Note](../path/to/my-note.md)``[[my-note]]`
**Usage:**
1. Place your cursor inside a wikilink or markdown link
2. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)
3. Type "Foam: Convert" and select the desired conversion command

View File

@@ -23,10 +23,8 @@ Related to [[Data Science]] and [[Statistics]].
Related to [[Data Science]] and [[Statistics]].
[//begin]: # 'Autogenerated link references for markdown compatibility'
[Data Science]: data-science.md 'Data Science'
[Statistics]: statistics.md 'Statistics'
[//end]: # 'Autogenerated link references'
```
## Enabling Reference Definitions
@@ -60,6 +58,3 @@ If you are using your notes only within Foam, you can keep definitions `off` (al
- **Publishing platforms** - Compatible with GitHub Pages, Jekyll, etc.
- **Future-proofing** - Not locked into Foam-specific format
- **Team collaboration** - Others can read notes without Foam

View File

@@ -27,12 +27,12 @@ This sets the `type` of this document to `feature` and sets **three** keywords f
Some properties have special meaning for Foam:
| Name | Description |
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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]]) |
| Name | Description |
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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]]) |
| `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. |
| `tags` | can be used to add tags to a note (see [[tags]]) |
| `alias` | can be used to add aliases to the note. an alias will show up in the link autocompletion |
| `tags` | can be used to add tags to a note (see [[tags]]) |
| `alias` | can be used to add aliases to the note. an alias will show up in the link autocompletion |
For example:
@@ -51,7 +51,8 @@ There also exists properties that are even more specific to Foam templates, see
[//begin]: # "Autogenerated link references for markdown compatibility"
[write-notes-in-foam]: ../getting-started/write-notes-in-foam.md "Writing Notes"
[graph-visualization]: graph-visualization.md "Graph Visualization"
[tags]: tags.md "Tags"
[graph-view]: ../features/graph-view.md "Graph Visualization"
[note-taking-in-foam]: ../getting-started/note-taking-in-foam.md "Note-Taking in Foam"
[note-templates#Metadata]: note-templates.md "Note Templates"
[//end]: # "Autogenerated link references"

View File

@@ -37,7 +37,6 @@ Create tag hierarchies using forward slashes:
#personal/health/exercise
```
## Autocompletion
Typing `#` shows existing tags. In front matter, use `Ctrl+Space` for tag suggestions.
@@ -50,9 +49,21 @@ Use the Tag Explorer panel in VS Code's sidebar to:
- Filter by tag names
- Click tags to see all associated notes
- View tag usage counts
- Search for tags (click the search icon or use "Foam: Search Tag" command)
Tags also appear in the [[graph-view]] with customizable colors.
## Tag Search
Search for all occurrences of a tag across your workspace:
1. Use the command palette: "Foam: Search Tag"
2. Or click the search icon next to a tag in the Tag Explorer panel
Results appear in VS Code's search panel where you can navigate between matches.
> 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.
## Custom Tag Styling
Customize tag appearance in markdown preview by adding CSS:
@@ -76,6 +87,4 @@ Customize tag appearance in markdown preview by adding CSS:
Some users prefer [[book]] backlinks instead of #book tags for categorization. Both approaches work - choose what fits your workflow.
[//begin]: # "Autogenerated link references for markdown compatibility"
[graph-view]: graph-view.md "Graph Visualization"
[//end]: # "Autogenerated link references"
[graph-view]: graph-view.md 'Graph Visualization'

View File

@@ -258,6 +258,7 @@ Supported variables include:
- `FOAM_DATE_YEAR`: 4-digit year (e.g. 2025)
- `FOAM_DATE_MONTH`: 2-digit month (e.g. 09)
- `FOAM_DATE_WEEK`: ISO 8601 week number (e.g. 37)
- `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`.
- `FOAM_DATE_DAY_ISO`: ISO 8601 weekday number (1-7, where Monday=1, Sunday=7)
- `FOAM_DATE_DATE`: 2-digit day of month (e.g. 15)
- `FOAM_DATE_DAY_NAME`: Full weekday name (e.g. Monday)
@@ -397,6 +398,4 @@ existing_frontmatter: 'Existing Frontmatter block'
This is the rest of the template
```
[//begin]: # 'Autogenerated link references for markdown compatibility'
[daily-notes]: daily-notes.md 'Daily Notes'
[//end]: # 'Autogenerated link references'

View File

@@ -28,7 +28,7 @@ Examples:
## Markdown Compatibility
Foam can automatically generates [[link-reference-definitions]] at the bottom of files to make wikilinks compatible with standard Markdown processors.
Foam can automatically generate [[link-reference-definitions]] at the bottom of files to make wikilinks compatible with standard Markdown processors.
## Related
@@ -36,10 +36,8 @@ Foam can automatically generates [[link-reference-definitions]] at the bottom of
- [[templates]] - Creating new notes
- [[link-reference-definition-improvements]] - Current limitations
[//begin]: # 'Autogenerated link references for markdown compatibility'
[graph-visualization]: graph-visualization.md 'Graph Visualization'
[link-reference-definitions]: link-reference-definitions.md 'Link Reference Definitions'
[foam-file-format]: ../../dev/foam-file-format.md 'Foam File Format'
[note-templates]: note-templates.md 'Note Templates'
[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md 'Link Reference Definition Improvements'
[//end]: # 'Autogenerated link references'

View File

@@ -14,15 +14,16 @@
- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]
## I don't want Foam enabled for all my workspaces
Any 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)
## I want to publish the graph view to GitHub pages or Vercel
If 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:
- [foam-gatsby](https://github.com/mathieudutour/foam-gatsby-template) by [Mathieu Dutour](https://github.com/mathieudutour)
- [foam-gatsby-kb](https://github.com/hikerpig/foam-template-gatsby-kb) by [hikerpig](https://github.com/hikerpig)
[//begin]: # "Autogenerated link references for markdown compatibility"
[recommended-extensions]: getting-started/recommended-extensions.md "Recommended Extensions"
[foam-file-format]: ../dev/foam-file-format.md "Foam File Format"
[wikilinks]: features/wikilinks.md "Wikilinks"
[//end]: # "Autogenerated link references"
[recommended-extensions]: getting-started/recommended-extensions.md 'Recommended Extensions'
[foam-file-format]: ../dev/foam-file-format.md 'Foam File Format'
[wikilinks]: features/wikilinks.md 'Wikilinks'

View File

@@ -195,10 +195,10 @@ You can also use other VS Code extensions to manage the git synching if that's h
With your workspace set up, you're ready to:
1. **[Learn note-taking fundamentals](note-taking)** - Master Markdown and writing effective notes
1. **[Learn note-taking fundamentals](note-taking-in-foam.md)** - Master Markdown and writing effective notes
2. **[Explore navigation](navigation.md)** - Connect your thoughts with wikilinks
3. **[Discover the graph view](graph-view.md)** - Visualize your knowledge network
4. **[Set up templates](templates)** - Standardize your note creation process
3. **[Discover the graph view](../features/graph-view.md)** - Visualize your knowledge network
4. **[Set up templates](../features/templates.md)** - Standardize your note creation process
## Getting Help

View File

@@ -38,7 +38,7 @@ The Foam extension adds knowledge management superpowers to VS Code.
## Step 3: Install Recommended Extensions
While 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 functionility of your notes.
While 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.
### Useful Extensions

View File

@@ -134,6 +134,6 @@ Currently you cannot rename whole folders.
With navigation mastered, you're ready to:
1. **[Explore the graph view](../features/graph-view.md)** - Visualize your knowledge network
2. **[Learn about backlinks](../features/backlinks.md)** - Master bidirectional linking
2. **[Learn about backlinks](../features/backlinking.md)** - Master bidirectional linking
3. **[Set up templates](../features/templates.md)** - Standardize your note creation
4. **[Use tags effectively](../features/tags.md)** - Add another layer of organization

View File

@@ -111,7 +111,7 @@ This embeds the "Key Principles" section from the Project Management note.
### Tags
Organize your content with [[docs-v2/user/features/tags]]:
Organize your content with [[tags]]:
```markdown
#productivity #learning #foam
@@ -233,6 +233,6 @@ Now that you understand note-taking basics:
3. **[Set up templates](../features/templates.md)** - Create reusable note structures
4. **[Use daily notes](../features/daily-notes.md)** - Establish a daily capture routine
[//begin]: # 'Autogenerated link references for markdown compatibility'
[navigation]: navigation.md 'Navigation in Foam'
[//end]: # 'Autogenerated link references'
[tags]: ../features/tags.md 'Tags'

View File

@@ -1,6 +1,6 @@
# Sync notes with source control
Source control is a way to precicely manage the history and content of a directory of files.
Source control is a way to precisely manage the history and content of a directory of files.
Often used for program code, this feature is very useful for note taking as well.
There are (too) many ways to commit your changes to source control:

View File

@@ -40,7 +40,6 @@ Foam is like a bathtub: _What you get out of it depends on what you put into it.
- [[tags]]
- [[backlinking]]
- [[daily-notes]]
- [[embeds]]
- [[spell-checking]]
- [[graph-view]]
- [[note-properties]]

View File

@@ -46,7 +46,6 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
- Add and explore [[tags]]
- Create [[templates]]
- Find [[orphans]]
- Use custom [[note-macros]] to create weekly, monthly etc. notes
- Draw [[diagrams-in-markdown]]
- Prettify your links, [[automatically-expand-urls-to-well-titled-links]]
- Style your environment with [[custom-markdown-preview-styles]]
@@ -124,7 +123,6 @@ _See [[contribution-guide]] and [[how-to-write-recipes]]._
[tags]: ../features/tags.md "Tags"
[note-templates]: ../features/note-templates.md "Note Templates"
[orphans]: ../tools/orphans.md "Orphaned Notes"
[note-macros]: note-macros.md "Custom Note Macros"
[diagrams-in-markdown]: diagrams-in-markdown.md "Diagrams in Markdown"
[automatically-expand-urls-to-well-titled-links]: automatically-expand-urls-to-well-titled-links.md "Automatically Expand URLs to Well-Titled Links"
[custom-markdown-preview-styles]: ../features/custom-markdown-preview-styles.md "Custom Markdown Preview Styles"

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.28.0"
"version": "0.29.1"
}

View File

@@ -17,6 +17,7 @@
"clean": "lerna run clean",
"build": "lerna run build",
"test": "yarn workspace foam-vscode test",
"test:unit": "yarn workspace foam-vscode test:unit",
"lint": "lerna run lint",
"watch": "lerna run watch --concurrency 20"
},

View File

@@ -4,6 +4,47 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
## 0.29.1
Fixes and Improvements:
- Load graph in code server (#1400)
- Don't treat text in single brackets as links/placeholders if missing a ref (#1545, #1546)
- Added option to show graph on startup (#1542)
- Added include patterns for Foam notes (#1550, #1422)
- Added support for emoji variants in tags (#1536, #1549)
- Added support for wikilink with aliases within tables (#1544, #1552)
## 0.29.0
Fixes and Improvements:
- Improved support for wikilink references (#1531, #1116, #1504)
- Improved tag search to include YAML tags (#1530, #1516)
- Improved template filepath sanitization (#1533)
- Added FOAM_DATE_WEEK_YEAR (#1532 - thanks @ChThH)
- Fixed graph panel moving when revealed - graph now stays in its current location (#1540)
## [0.28.3] - 2025-10-03
Fixes and Improvements:
- Fixed sanitation of filepath for templates (#1529 #1526)
## [0.28.2] - 2025-10-01
Fixes and Improvements:
- Fixed build for web extension (#1523)
## [0.28.1] - 2025-09-25
Fixes and Improvements:
- Removed duplicate links in dataviz graph (#1511 - thanks @Tenormis)
- Use letter case to further disambiguate note identifiers (#1519, #1303)
- Sanitize `filepath` before creating note from template (#1520, #1216)
## [0.28.0] - 2025-09-24
Features:

View File

@@ -1,9 +1,10 @@
# Foam for VSCode
[![Version](https://img.shields.io/visual-studio-marketplace/v/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Downloads](https://img.shields.io/visual-studio-marketplace/d/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Version](https://img.shields.io/visual-studio-marketplace/v/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Downloads](https://img.shields.io/visual-studio-marketplace/d/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![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)
[![Ratings](https://img.shields.io/visual-studio-marketplace/r/foam.foam-vscode?cacheSeconds=3600)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
[![Discord Chat](https://img.shields.io/discord/729975036148056075?color=748AD9&label=discord%20chat&style=flat-square)](https://foambubble.github.io/join-discord/g)
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)

View File

@@ -25,6 +25,9 @@ const config = {
platform: 'browser',
format: 'cjs',
outfile: `out/bundles/extension-web.js`,
define: {
global: 'globalThis',
},
plugins: [
polyfillPlugin.polyfillNode({
// Options (optional)

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.28.0",
"version": "0.29.1",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -82,6 +82,13 @@
"name": "Placeholders",
"icon": "$(debug-disconnect)",
"contextualTitle": "Foam"
},
{
"when": "config.foam.experimental.ai",
"id": "foam-vscode.related-notes",
"name": "Related Notes (AI)",
"icon": "$(sparkle)",
"contextualTitle": "Foam"
}
]
},
@@ -101,6 +108,21 @@
{
"view": "foam-vscode.placeholders",
"contents": "No placeholders found for selected resource or workspace."
},
{
"view": "foam-vscode.related-notes",
"contents": "Open a note to see related notes.",
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'no-note'"
},
{
"view": "foam-vscode.related-notes",
"contents": "Notes haven't been analyzed yet.\n[Analyze Notes](command:foam-vscode.build-embeddings)\nAnalyze your notes to discover similar content.",
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'no-embedding'"
},
{
"view": "foam-vscode.related-notes",
"contents": "No similar notes found for the current note.",
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'ready'"
}
],
"menus": {
@@ -367,12 +389,12 @@
"title": "Foam: Open Resource"
},
{
"command": "foam-vscode.convert-link-style-inplace",
"title": "Foam: Convert Link Style in Place"
"command": "foam-vscode.convert-wikilink-to-mdlink",
"title": "Foam: Convert Wikilink to Markdown Link"
},
{
"command": "foam-vscode.convert-link-style-incopy",
"title": "Foam: Convert Link Format in Copy"
"command": "foam-vscode.convert-mdlink-to-wikilink",
"title": "Foam: Convert Markdown Link to Wikilink"
},
{
"command": "foam-vscode.search-tag",
@@ -384,6 +406,16 @@
"title": "Foam: Rename Tag",
"icon": "$(edit)"
},
{
"command": "foam-vscode.show-similar-notes",
"title": "Foam: Show Similar Notes",
"when": "config.foam.experimental.ai"
},
{
"command": "foam-vscode.build-embeddings",
"title": "Foam: Build Embeddings Index",
"when": "config.foam.experimental.ai"
},
{
"command": "foam-vscode.views.orphans.group-by:folder",
"title": "Group By Folder",
@@ -526,6 +558,19 @@
"Use alias if resource path is different from title"
]
},
"foam.completion.linkFormat": {
"type": "string",
"default": "wikilink",
"description": "Controls the format of completed links",
"enum": [
"wikilink",
"link"
],
"enumDescriptions": [
"Complete as wikilinks (e.g., [[note-name]])",
"Complete as markdown links (e.g., [Note Name](note-name.md))"
]
},
"foam.files.ignore": {
"type": [
"array"
@@ -536,7 +581,24 @@
"**/_site/**/*",
"**/node_modules/**/*"
],
"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 the all the content of a given folder, use `<folderName>/**/*`"
"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>/**/*`",
"deprecationMessage": "Use 'foam.files.exclude' instead. This setting will be removed in a future version."
},
"foam.files.exclude": {
"type": [
"array"
],
"default": [],
"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'."
},
"foam.files.include": {
"type": [
"array"
],
"default": [
"**/*"
],
"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."
},
"foam.files.attachmentExtensions": {
"type": "string",
@@ -664,6 +726,7 @@
"description": "Whether or not to navigate to the target daily note when a daily note snippet is selected."
},
"foam.preview.embedNoteType": {
"when": "config.foam.experimental.ai",
"type": "string",
"default": "full-card",
"enum": [
@@ -688,6 +751,11 @@
"type": "object",
"description": "Custom graph styling settings. An example is present in the documentation.",
"default": {}
},
"foam.graph.onStartup": {
"type": "boolean",
"default": false,
"description": "Whether to open the graph on startup."
}
}
},
@@ -711,8 +779,8 @@
"test-reset-workspace": "rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep",
"test-setup": "yarn compile && yarn build && yarn test-reset-workspace",
"test": "yarn test-setup && node ./out/test/run-tests.js",
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs",
"test:unit-with-specs": "yarn test-setup && node ./out/test/run-tests.js --unit",
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit",
"test:unit-without-specs": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs",
"test:e2e": "yarn test-setup && node ./out/test/run-tests.js --e2e",
"lint": "dts lint src",
"clean": "rimraf out",

View File

@@ -0,0 +1,17 @@
import { URI } from '../../core/model/uri';
import { ICache } from '../../core/utils/cache';
type Checksum = string;
/**
* Cache entry for embeddings
*/
export interface EmbeddingCacheEntry {
checksum: Checksum;
embedding: number[];
}
/**
* Cache for embeddings, keyed by URI
*/
export type EmbeddingCache = ICache<URI, EmbeddingCacheEntry>;

View File

@@ -0,0 +1,365 @@
import { FoamEmbeddings } from './embeddings';
import {
EmbeddingProvider,
EmbeddingProviderInfo,
} from '../services/embedding-provider';
import {
createTestWorkspace,
InMemoryDataStore,
waitForExpect,
} from '../../test/test-utils';
import { URI } from '../../core/model/uri';
// Helper to create a simple mock provider
class MockProvider implements EmbeddingProvider {
async embed(text: string): Promise<number[]> {
const vector = new Array(384).fill(0);
vector[0] = text.length / 100; // Deterministic based on text length
return vector;
}
async isAvailable(): Promise<boolean> {
return true;
}
getProviderInfo(): EmbeddingProviderInfo {
return {
name: 'Test Provider',
type: 'local',
model: { name: 'test-model', dimensions: 384 },
};
}
}
const ROOT = [URI.parse('/', 'file')];
describe('FoamEmbeddings', () => {
describe('cosineSimilarity', () => {
it('should return 1 for identical vectors', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const vector = [1, 2, 3, 4, 5];
const similarity = embeddings.cosineSimilarity(vector, vector);
expect(similarity).toBeCloseTo(1.0, 5);
workspace.dispose();
});
it('should return 0 for orthogonal vectors', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const vec1 = [1, 0, 0];
const vec2 = [0, 1, 0];
const similarity = embeddings.cosineSimilarity(vec1, vec2);
expect(similarity).toBeCloseTo(0.0, 5);
workspace.dispose();
});
it('should return -1 for opposite vectors', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const vec1 = [1, 0, 0];
const vec2 = [-1, 0, 0];
const similarity = embeddings.cosineSimilarity(vec1, vec2);
expect(similarity).toBeCloseTo(-1.0, 5);
workspace.dispose();
});
it('should return 0 for zero vectors', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const vec1 = [0, 0, 0];
const vec2 = [1, 2, 3];
const similarity = embeddings.cosineSimilarity(vec1, vec2);
expect(similarity).toBe(0);
workspace.dispose();
});
it('should throw error for vectors of different lengths', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const vec1 = [1, 2, 3];
const vec2 = [1, 2];
expect(() => embeddings.cosineSimilarity(vec1, vec2)).toThrow();
workspace.dispose();
});
});
describe('updateResource', () => {
it('should create embedding for a resource', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const noteUri = URI.parse('/path/to/note.md', 'file');
datastore.set(noteUri, '# Test Note\n\nThis is test content');
await workspace.fetchAndSet(noteUri);
await embeddings.updateResource(noteUri);
const embedding = embeddings.getEmbedding(noteUri);
expect(embedding).not.toBeNull();
expect(embedding?.length).toBe(384);
workspace.dispose();
});
it('should remove embedding when resource is deleted', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const noteUri = URI.parse('/path/to/note.md', 'file');
datastore.set(noteUri, '# Test Note\n\nContent');
await workspace.fetchAndSet(noteUri);
await embeddings.updateResource(noteUri);
expect(embeddings.getEmbedding(noteUri)).not.toBeNull();
workspace.delete(noteUri);
await embeddings.updateResource(noteUri);
expect(embeddings.getEmbedding(noteUri)).toBeNull();
workspace.dispose();
});
it('should create different embeddings for different content', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const note1Uri = URI.parse('/note1.md', 'file');
const note2Uri = URI.parse('/note2.md', 'file');
// Same title, different content
datastore.set(note1Uri, '# Same Title\n\nShort content');
datastore.set(
note2Uri,
'# Same Title\n\nThis is much longer content that should produce a different embedding vector'
);
await workspace.fetchAndSet(note1Uri);
await workspace.fetchAndSet(note2Uri);
await embeddings.updateResource(note1Uri);
await embeddings.updateResource(note2Uri);
const embedding1 = embeddings.getEmbedding(note1Uri);
const embedding2 = embeddings.getEmbedding(note2Uri);
expect(embedding1).not.toBeNull();
expect(embedding2).not.toBeNull();
// Embeddings should be different because content is different
// Our mock provider uses text.length for the first vector component
expect(embedding1![0]).not.toBe(embedding2![0]);
workspace.dispose();
});
});
describe('hasEmbeddings', () => {
it('should return false when no embeddings exist', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
expect(embeddings.hasEmbeddings()).toBe(false);
workspace.dispose();
});
it('should return true when embeddings exist', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const noteUri = URI.parse('/path/to/note.md', 'file');
datastore.set(noteUri, '# Note\n\nContent');
await workspace.fetchAndSet(noteUri);
await embeddings.updateResource(noteUri);
expect(embeddings.hasEmbeddings()).toBe(true);
workspace.dispose();
});
});
describe('getSimilar', () => {
it('should return empty array when no embedding exists for target', () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const uri = URI.parse('/path/to/note.md', 'file');
const similar = embeddings.getSimilar(uri, 5);
expect(similar).toEqual([]);
workspace.dispose();
});
it('should return similar notes sorted by similarity', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
// Create notes with different content lengths
const note1Uri = URI.parse('/note1.md', 'file');
const note2Uri = URI.parse('/note2.md', 'file');
const note3Uri = URI.parse('/note3.md', 'file');
datastore.set(note1Uri, '# Note 1\n\nShort');
datastore.set(note2Uri, '# Note 2\n\nMedium length text');
datastore.set(note3Uri, '# Note 3\n\nVery long text content here');
await workspace.fetchAndSet(note1Uri);
await workspace.fetchAndSet(note2Uri);
await workspace.fetchAndSet(note3Uri);
await embeddings.updateResource(note1Uri);
await embeddings.updateResource(note2Uri);
await embeddings.updateResource(note3Uri);
// Get similar to note2
const similar = embeddings.getSimilar(note2Uri, 10);
expect(similar.length).toBe(2); // Excludes self
expect(similar[0].uri.path).toBeTruthy();
expect(similar[0].similarity).toBeGreaterThanOrEqual(
similar[1].similarity
);
workspace.dispose();
});
it('should respect topK parameter', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
// Create multiple notes
for (let i = 0; i < 10; i++) {
const noteUri = URI.parse(`/note${i}.md`, 'file');
datastore.set(noteUri, `# Note ${i}\n\nContent ${i}`);
await workspace.fetchAndSet(noteUri);
await embeddings.updateResource(noteUri);
}
const target = URI.parse('/note0.md', 'file');
const similar = embeddings.getSimilar(target, 3);
expect(similar.length).toBe(3);
workspace.dispose();
});
it('should not include self in similar results', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
const noteUri = URI.parse('/note.md', 'file');
datastore.set(noteUri, '# Note\n\nContent');
await workspace.fetchAndSet(noteUri);
await embeddings.updateResource(noteUri);
const similar = embeddings.getSimilar(noteUri, 10);
expect(similar.find(s => s.uri.path === noteUri.path)).toBeUndefined();
workspace.dispose();
});
});
describe('fromWorkspace with monitoring', () => {
it('should automatically update when resource is added', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const embeddings = FoamEmbeddings.fromWorkspace(
workspace,
new MockProvider(),
true
);
const noteUri = URI.parse('/new-note.md', 'file');
datastore.set(noteUri, '# New Note\n\nContent');
await workspace.fetchAndSet(noteUri);
// Give it a moment to process
await new Promise(resolve => setTimeout(resolve, 100));
const embedding = embeddings.getEmbedding(noteUri);
expect(embedding).not.toBeNull();
embeddings.dispose();
workspace.dispose();
});
it('should automatically update when resource is modified', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const noteUri = URI.parse('/note.md', 'file');
datastore.set(noteUri, '# Note\n\nOriginal content');
await workspace.fetchAndSet(noteUri);
const embeddings = FoamEmbeddings.fromWorkspace(
workspace,
new MockProvider(),
true
);
await embeddings.updateResource(noteUri);
const originalEmbedding = embeddings.getEmbedding(noteUri);
// Update the content of the note to simulate a change
datastore.set(noteUri, '# Note\n\nDifferent content that is much longer');
// Trigger workspace update event
await workspace.fetchAndSet(noteUri);
// Wait for automatic update
await waitForExpect(
() => {
const newEmbedding = embeddings.getEmbedding(noteUri);
expect(newEmbedding).not.toEqual(originalEmbedding);
},
1000,
50
);
embeddings.dispose();
workspace.dispose();
});
it('should automatically remove embedding when resource is deleted', async () => {
const datastore = new InMemoryDataStore();
const workspace = createTestWorkspace(ROOT, datastore);
const noteUri = URI.parse('/note.md', 'file');
datastore.set(noteUri, '# Note\n\nContent');
await workspace.fetchAndSet(noteUri);
const embeddings = FoamEmbeddings.fromWorkspace(
workspace,
new MockProvider(),
true
);
await embeddings.updateResource(noteUri);
expect(embeddings.getEmbedding(noteUri)).not.toBeNull();
workspace.delete(noteUri);
// Give it a moment to process
await new Promise(resolve => setTimeout(resolve, 50));
expect(embeddings.getEmbedding(noteUri)).toBeNull();
embeddings.dispose();
workspace.dispose();
});
});
});

View File

@@ -0,0 +1,382 @@
import { Emitter } from '../../core/common/event';
import { IDisposable } from '../../core/common/lifecycle';
import { Logger } from '../../core/utils/log';
import { hash } from '../../core/utils';
import { EmbeddingProvider, Embedding } from '../services/embedding-provider';
import { EmbeddingCache } from './embedding-cache';
import {
ProgressCallback,
CancellationToken,
CancellationError,
} from '../../core/services/progress';
import { FoamWorkspace } from '../../core/model/workspace';
import { URI } from '../../core/model/uri';
/**
* Represents a similar resource with its similarity score
*/
export interface SimilarResource {
uri: URI;
similarity: number;
}
/**
* Context information for embedding progress
*/
export interface EmbeddingProgressContext {
/** URI of the current resource */
uri: URI;
/** Title of the current resource */
title: string;
}
/**
* Manages embeddings for all resources in the workspace
*/
export class FoamEmbeddings implements IDisposable {
/**
* Maps resource URIs to their embeddings
*/
private embeddings: Map<string, Embedding> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
/**
* List of disposables to destroy with the embeddings
*/
private disposables: IDisposable[] = [];
constructor(
private readonly workspace: FoamWorkspace,
private readonly provider: EmbeddingProvider,
private readonly cache?: EmbeddingCache
) {}
/**
* Get the embedding for a resource
* @param uri The URI of the resource
* @returns The embedding vector, or null if not found
*/
public getEmbedding(uri: URI): number[] | null {
const embedding = this.embeddings.get(uri.path);
return embedding ? embedding.vector : null;
}
/**
* Check if embeddings are available
* @returns true if at least one embedding exists
*/
public hasEmbeddings(): boolean {
return this.embeddings.size > 0;
}
/**
* Get the number of embeddings
* @returns The count of embeddings
*/
public size(): number {
return this.embeddings.size;
}
/**
* Find similar resources to a given resource
* @param uri The URI of the target resource
* @param topK The number of similar resources to return
* @returns Array of similar resources sorted by similarity (highest first)
*/
public getSimilar(uri: URI, topK: number = 10): SimilarResource[] {
const targetEmbedding = this.getEmbedding(uri);
if (!targetEmbedding) {
return [];
}
const similarities: SimilarResource[] = [];
for (const [path, embedding] of this.embeddings.entries()) {
// Skip self
if (path === uri.path) {
continue;
}
const similarity = this.cosineSimilarity(
targetEmbedding,
embedding.vector
);
similarities.push({
uri: URI.file(path),
similarity,
});
}
// Sort by similarity (highest first) and take top K
similarities.sort((a, b) => b.similarity - a.similarity);
return similarities.slice(0, topK);
}
/**
* Calculate cosine similarity between two vectors
* @param a First vector
* @param b Second vector
* @returns Similarity score between -1 and 1 (higher is more similar)
*/
public cosineSimilarity(a: number[], b: number[]): number {
if (a.length !== b.length) {
throw new Error('Vectors must have the same length');
}
let dotProduct = 0;
let normA = 0;
let normB = 0;
for (let i = 0; i < a.length; i++) {
dotProduct += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
if (denominator === 0) {
return 0;
}
return dotProduct / denominator;
}
/**
* Update embeddings for a single resource
* @param uri The URI of the resource to update
* @returns The embedding vector, or null if not found/not processed
*/
public async updateResource(uri: URI): Promise<Embedding | null> {
const resource = this.workspace.find(uri);
if (!resource) {
// Resource deleted, remove embedding
this.embeddings.delete(uri.path);
if (this.cache) {
this.cache.del(uri);
}
this.onDidUpdateEmitter.fire();
return null;
}
// Skip non-note resources (attachments)
if (resource.type !== 'note') {
return null;
}
try {
const content = await this.workspace.readAsMarkdown(resource.uri);
const text = this.prepareTextForEmbedding(resource.title, content);
const textChecksum = hash(text);
// Check cache if available
if (this.cache && this.cache.has(uri)) {
const cached = this.cache.get(uri);
if (cached.checksum === textChecksum) {
Logger.debug(
`Skipping embedding for ${uri.toFsPath()} - content unchanged`
);
// Use cached embedding
const embedding: Embedding = {
vector: cached.embedding,
createdAt: Date.now(),
};
this.embeddings.set(uri.path, embedding);
return embedding;
}
}
// Generate new embedding
const vector = await this.provider.embed(text);
const embedding: Embedding = {
vector,
createdAt: Date.now(),
};
this.embeddings.set(uri.path, embedding);
// Update cache
if (this.cache) {
this.cache.set(uri, {
checksum: textChecksum,
embedding: vector,
});
}
this.onDidUpdateEmitter.fire();
return embedding;
} catch (error) {
Logger.error(`Failed to update embedding for ${uri.toFsPath()}`, error);
return null;
}
}
/**
* Update embeddings for all notes, processing only missing or stale ones
* @param onProgress Optional callback to report progress
* @param cancellationToken Optional token to cancel the operation
* @returns Promise that resolves when all embeddings are updated
* @throws CancellationError if the operation is cancelled
*/
public async update(
onProgress?: ProgressCallback<EmbeddingProgressContext>,
cancellationToken?: CancellationToken
): Promise<void> {
const start = Date.now();
// Filter to only process notes (not attachments)
const allResources = Array.from(this.workspace.resources());
const resources = allResources.filter(r => r.type === 'note');
Logger.info(
`Building embeddings for ${resources.length} notes (${allResources.length} total resources)...`
);
let skipped = 0;
let generated = 0;
let reused = 0;
// Process embeddings sequentially to avoid overwhelming the service
for (let i = 0; i < resources.length; i++) {
// Check for cancellation
if (cancellationToken?.isCancellationRequested) {
Logger.info(
`Embedding build cancelled. Processed ${i}/${resources.length} notes.`
);
throw new CancellationError('Embedding build cancelled');
}
const resource = resources[i];
onProgress?.({
current: i + 1,
total: resources.length,
context: {
uri: resource.uri,
title: resource.title,
},
});
try {
const content = await this.workspace.readAsMarkdown(resource.uri);
const text = this.prepareTextForEmbedding(resource.title, content);
const textChecksum = hash(text);
// Check cache if available
if (this.cache && this.cache.has(resource.uri)) {
const cached = this.cache.get(resource.uri);
if (cached.checksum === textChecksum) {
// Check if we already have this embedding in memory
const existing = this.embeddings.get(resource.uri.path);
if (existing) {
// Already have current embedding, skip
reused++;
continue;
}
// Restore from cache
this.embeddings.set(resource.uri.path, {
vector: cached.embedding,
createdAt: Date.now(),
});
skipped++;
continue;
}
}
// Generate new embedding
const vector = await this.provider.embed(text);
this.embeddings.set(resource.uri.path, {
vector,
createdAt: Date.now(),
});
// Update cache
if (this.cache) {
this.cache.set(resource.uri, {
checksum: textChecksum,
embedding: vector,
});
}
generated++;
} catch (error) {
Logger.error(
`Failed to generate embedding for ${resource.uri.toFsPath()}`,
error
);
}
}
const end = Date.now();
Logger.info(
`Embeddings update complete: ${generated} generated, ${skipped} from cache, ${reused} already current (${
this.embeddings.size
}/${resources.length} total) in ${end - start}ms`
);
this.onDidUpdateEmitter.fire();
}
/**
* Prepare text for embedding by combining title and content
* @param title The title of the note
* @param content The markdown content of the note
* @returns The combined text to embed
*/
private prepareTextForEmbedding(title: string, content: string): string {
const parts: string[] = [];
if (title) {
parts.push(title);
}
if (content) {
parts.push(content);
}
return parts.join('\n\n');
}
/**
* Create FoamEmbeddings from a workspace
* @param workspace The workspace to generate embeddings for
* @param provider The embedding provider to use
* @param keepMonitoring Whether to automatically update embeddings when workspace changes
* @param cache Optional cache for storing embeddings
* @returns The FoamEmbeddings instance
*/
public static fromWorkspace(
workspace: FoamWorkspace,
provider: EmbeddingProvider,
keepMonitoring: boolean = false,
cache?: EmbeddingCache
): FoamEmbeddings {
const embeddings = new FoamEmbeddings(workspace, provider, cache);
if (keepMonitoring) {
// Update embeddings when resources change
embeddings.disposables.push(
workspace.onDidAdd(resource => {
embeddings.updateResource(resource.uri);
}),
workspace.onDidUpdate(({ new: resource }) => {
embeddings.updateResource(resource.uri);
}),
workspace.onDidDelete(resource => {
embeddings.embeddings.delete(resource.uri.path);
embeddings.onDidUpdateEmitter.fire();
})
);
}
return embeddings;
}
public dispose(): void {
this.onDidUpdateEmitter.dispose();
this.disposables.forEach(d => d.dispose());
this.disposables = [];
this.embeddings.clear();
}
}

View File

@@ -0,0 +1,29 @@
import { URI } from '../../core/model/uri';
import { EmbeddingCache, EmbeddingCacheEntry } from './embedding-cache';
/**
* Simple in-memory implementation of embedding cache
*/
export class InMemoryEmbeddingCache implements EmbeddingCache {
private cache: Map<string, EmbeddingCacheEntry> = new Map();
get(uri: URI): EmbeddingCacheEntry {
return this.cache.get(uri.toString());
}
has(uri: URI): boolean {
return this.cache.has(uri.toString());
}
set(uri: URI, entry: EmbeddingCacheEntry): void {
this.cache.set(uri.toString(), entry);
}
del(uri: URI): void {
this.cache.delete(uri.toString());
}
clear(): void {
this.cache.clear();
}
}

View File

@@ -0,0 +1,294 @@
import { Logger } from '../../../core/utils/log';
import {
OllamaEmbeddingProvider,
DEFAULT_OLLAMA_CONFIG,
} from './ollama-provider';
Logger.setLevel('error');
describe('OllamaEmbeddingProvider', () => {
const originalFetch = global.fetch;
beforeEach(() => {
global.fetch = jest.fn();
jest.clearAllMocks();
jest.useFakeTimers();
});
afterEach(() => {
jest.useRealTimers();
global.fetch = originalFetch;
});
describe('constructor', () => {
it('should use default config when no config provided', () => {
const provider = new OllamaEmbeddingProvider();
const config = provider.getConfig();
expect(config.url).toBe(DEFAULT_OLLAMA_CONFIG.url);
expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);
expect(config.timeout).toBe(DEFAULT_OLLAMA_CONFIG.timeout);
});
it('should merge custom config with defaults', () => {
const provider = new OllamaEmbeddingProvider({
url: 'http://custom:11434',
});
const config = provider.getConfig();
expect(config.url).toBe('http://custom:11434');
expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);
});
});
describe('getProviderInfo', () => {
it('should return provider information', () => {
const provider = new OllamaEmbeddingProvider();
const info = provider.getProviderInfo();
expect(info.name).toBe('Ollama');
expect(info.type).toBe('local');
expect(info.model.name).toBe('nomic-embed-text');
expect(info.model.dimensions).toBe(768);
expect(info.endpoint).toBe('http://localhost:11434');
expect(info.description).toBe('Local embedding provider using Ollama');
expect(info.metadata).toEqual({ timeout: 30000 });
});
it('should return custom model name when configured', () => {
const provider = new OllamaEmbeddingProvider({
model: 'custom-model',
});
const info = provider.getProviderInfo();
expect(info.model.name).toBe('custom-model');
});
it('should return custom endpoint when configured', () => {
const provider = new OllamaEmbeddingProvider({
url: 'http://custom:8080',
});
const info = provider.getProviderInfo();
expect(info.endpoint).toBe('http://custom:8080');
});
});
describe('embed', () => {
it('should successfully generate embeddings', async () => {
const mockEmbedding = new Array(768).fill(0.1);
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
json: async () => ({ embeddings: [mockEmbedding] }),
});
const provider = new OllamaEmbeddingProvider();
const result = await provider.embed('test text');
expect(result).toEqual(mockEmbedding);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:11434/api/embed',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'nomic-embed-text',
input: ['test text'],
}),
})
);
});
it('should throw error on non-ok response', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 500,
text: async () => 'Internal server error',
});
const provider = new OllamaEmbeddingProvider();
await expect(provider.embed('test')).rejects.toThrow(
'AI service error (500)'
);
});
it('should throw error on connection refused', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error('fetch failed')
);
const provider = new OllamaEmbeddingProvider();
await expect(provider.embed('test')).rejects.toThrow(
'Cannot connect to Ollama'
);
});
it('should timeout after configured duration', async () => {
(global.fetch as jest.Mock).mockImplementationOnce(
(_url, options) =>
new Promise((_resolve, reject) => {
// Simulate abort signal being triggered
options.signal.addEventListener('abort', () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
});
})
);
const provider = new OllamaEmbeddingProvider({ timeout: 1000 });
const embedPromise = provider.embed('test');
// Fast-forward time to trigger timeout
jest.advanceTimersByTime(1001);
await expect(embedPromise).rejects.toThrow('AI service took too long');
});
});
describe('isAvailable', () => {
it('should return true when Ollama is available', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: true,
});
const provider = new OllamaEmbeddingProvider();
const result = await provider.isAvailable();
expect(result).toBe(true);
expect(global.fetch).toHaveBeenCalledWith(
'http://localhost:11434/api/tags',
expect.objectContaining({
method: 'GET',
})
);
});
it('should return false when Ollama is not available', async () => {
(global.fetch as jest.Mock).mockRejectedValueOnce(
new Error('Connection refused')
);
const provider = new OllamaEmbeddingProvider();
const result = await provider.isAvailable();
expect(result).toBe(false);
});
it('should return false when Ollama returns non-ok status', async () => {
(global.fetch as jest.Mock).mockResolvedValueOnce({
ok: false,
status: 404,
});
const provider = new OllamaEmbeddingProvider();
const result = await provider.isAvailable();
expect(result).toBe(false);
});
it('should timeout quickly (5s) when checking availability', async () => {
(global.fetch as jest.Mock).mockImplementationOnce(
(_url, options) =>
new Promise((_resolve, reject) => {
// Simulate abort signal being triggered
options.signal.addEventListener('abort', () => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
});
})
);
const provider = new OllamaEmbeddingProvider();
const availabilityPromise = provider.isAvailable();
// Fast-forward time to trigger timeout (5s for availability check)
jest.advanceTimersByTime(5001);
const result = await availabilityPromise;
expect(result).toBe(false);
});
});
});
describe('OllamaEmbeddingProvider - Integration', () => {
const provider = new OllamaEmbeddingProvider();
it('should handle text with unicode characters and emojis', async () => {
if (!(await provider.isAvailable())) {
console.warn('Ollama is not available, skipping test');
return;
}
const text = 'Task completed ✔ 🚀: All systems go! 🌟';
const embedding = await provider.embed(text);
expect(embedding).toBeDefined();
expect(Array.isArray(embedding)).toBe(true);
expect(embedding.length).toBe(768); // nomic-embed-text dimension
expect(embedding.every(n => typeof n === 'number')).toBe(true);
});
it('should handle text with various unicode characters', async () => {
if (!(await provider.isAvailable())) {
console.warn('Ollama is not available, skipping test');
return;
}
const text = 'Hello 🌍 with émojis and spëcial çharacters • bullet ✓ check';
const embedding = await provider.embed(text);
expect(embedding).toBeDefined();
expect(Array.isArray(embedding)).toBe(true);
expect(embedding.length).toBe(768);
});
it('should handle text with combining unicode characters', async () => {
if (!(await provider.isAvailable())) {
console.warn('Ollama is not available, skipping test');
return;
}
// Test with combining diacriticals that could be represented differently
const text = 'café vs cafe\u0301'; // Two ways to represent é
const embedding = await provider.embed(text);
expect(embedding).toBeDefined();
expect(Array.isArray(embedding)).toBe(true);
expect(embedding.length).toBe(768);
});
it('should handle empty text', async () => {
if (!(await provider.isAvailable())) {
console.warn('Ollama is not available, skipping test');
return;
}
const text = '';
const embedding = await provider.embed(text);
expect(embedding).toBeDefined();
expect(Array.isArray(embedding)).toBe(true);
// Note: Ollama returns empty array for empty text
expect(embedding.length).toBeGreaterThanOrEqual(0);
});
it.each([10, 50, 60, 100, 300])(
'should handle text of various lengths',
async length => {
if (!(await provider.isAvailable())) {
console.warn('Ollama is not available, skipping test');
return;
}
const text = 'Lorem ipsum dolor sit amet. '.repeat(length);
try {
const embedding = await provider.embed(text);
expect(embedding).toBeDefined();
expect(Array.isArray(embedding)).toBe(true);
expect(embedding.length).toBe(768);
} catch (error) {
throw new Error(
`Embedding failed for text of length ${text.length}: ${error}`
);
}
}
);
});

View File

@@ -0,0 +1,166 @@
import {
EmbeddingProvider,
EmbeddingProviderInfo,
} from '../../services/embedding-provider';
import { Logger } from '../../../core/utils/log';
/**
* Configuration for Ollama embedding provider
*/
export interface OllamaConfig {
/** Base URL for Ollama API (default: http://localhost:11434) */
url: string;
/** Model name to use for embeddings (default: nomic-embed-text) */
model: string;
/** Request timeout in milliseconds (default: 30000) */
timeout: number;
}
/**
* Default configuration for Ollama
*/
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
url: 'http://localhost:11434',
model: 'nomic-embed-text',
timeout: 30000,
};
/**
* Ollama API response for embeddings
*/
interface OllamaEmbeddingResponse {
embeddings: number[][];
}
/**
* Embedding provider that uses Ollama for generating embeddings
*/
export class OllamaEmbeddingProvider implements EmbeddingProvider {
private config: OllamaConfig;
constructor(config: Partial<OllamaConfig> = {}) {
this.config = { ...DEFAULT_OLLAMA_CONFIG, ...config };
}
/**
* Generate an embedding for the given text
*/
async embed(text: string): Promise<number[]> {
// normalize text to suitable input (format and size)
// TODO we should better handle long texts by chunking them and averaging embeddings
const input = text.substring(0, 6000).normalize();
try {
const controller = new AbortController();
const timeoutId = setTimeout(
() => controller.abort(),
this.config.timeout
);
const response = await fetch(`${this.config.url}/api/embed`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: this.config.model,
input: [input],
}),
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`AI service error (${response.status}): ${errorText}`);
}
const data = await response.json();
if (data.embeddings == null) {
throw new Error(
`Invalid response from AI service: ${JSON.stringify(data)}`
);
}
return data.embeddings[0];
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
throw new Error(
'AI service took too long to respond. It may be busy processing another request.'
);
}
if (
error.message.includes('fetch') ||
error.message.includes('ECONNREFUSED')
) {
throw new Error(
`Cannot connect to Ollama at ${this.config.url}. Make sure Ollama is installed and running.`
);
}
}
throw error;
}
}
/**
* Check if Ollama is available and the model is accessible
*/
async isAvailable(): Promise<boolean> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000);
// Try to reach the Ollama API
const response = await fetch(`${this.config.url}/api/tags`, {
method: 'GET',
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
Logger.warn(
`Ollama API returned status ${response.status} when checking availability`
);
return false;
}
return true;
} catch (error) {
Logger.debug(
`Ollama not available at ${this.config.url}: ${
error instanceof Error ? error.message : 'Unknown error'
}`
);
return false;
}
}
/**
* Get provider information including model details
*/
getProviderInfo(): EmbeddingProviderInfo {
return {
name: 'Ollama',
type: 'local',
model: {
name: this.config.model,
// nomic-embed-text produces 768-dimensional embeddings
dimensions: 768,
},
description: 'Local embedding provider using Ollama',
endpoint: this.config.url,
metadata: {
timeout: this.config.timeout,
},
};
}
/**
* Get current configuration
*/
getConfig(): OllamaConfig {
return { ...this.config };
}
}

View File

@@ -0,0 +1,61 @@
/**
* Information about an embedding provider and its model
*/
export interface EmbeddingProviderInfo {
/** Human-readable name of the provider (e.g., "Ollama", "OpenAI") */
name: string;
/** Type of provider */
type: 'local' | 'remote';
/** Model information */
model: {
/** Model name (e.g., "nomic-embed-text", "text-embedding-3-small") */
name: string;
/** Vector dimensions */
dimensions: number;
};
/** Optional description of the provider */
description?: string;
/** Backend endpoint/URL if applicable */
endpoint?: string;
/** Additional provider-specific metadata */
metadata?: Record<string, unknown>;
}
/**
* Provider interface for generating text embeddings
*/
export interface EmbeddingProvider {
/**
* Generate an embedding vector for the given text
* @param text The text to embed
* @returns A promise that resolves to the embedding vector
*/
embed(text: string): Promise<number[]>;
/**
* Check if the embedding service is available and ready to use
* @returns A promise that resolves to true if available, false otherwise
*/
isAvailable(): Promise<boolean>;
/**
* Get information about the provider and its model
* @returns Provider metadata including name, type, model info, and configuration
*/
getProviderInfo(): EmbeddingProviderInfo;
}
/**
* Represents a text embedding with metadata
*/
export interface Embedding {
/** The embedding vector */
vector: number[];
/** Timestamp when the embedding was created */
createdAt: number;
}

View File

@@ -0,0 +1,27 @@
import { EmbeddingProvider, EmbeddingProviderInfo } from './embedding-provider';
/**
* A no-op embedding provider that does nothing.
* Used when no real embedding provider is available.
*/
export class NoOpEmbeddingProvider implements EmbeddingProvider {
async embed(_text: string): Promise<number[]> {
return [];
}
async isAvailable(): Promise<boolean> {
return false;
}
getProviderInfo(): EmbeddingProviderInfo {
return {
name: 'None',
type: 'local',
model: {
name: 'none',
dimensions: 0,
},
description: 'No embedding provider configured',
};
}
}

View File

@@ -0,0 +1,88 @@
/* @unit-ready */
import * as vscode from 'vscode';
import {
cleanWorkspace,
createFile,
deleteFile,
waitForNoteInFoamWorkspace,
} from '../../../test/test-utils-vscode';
import { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';
describe('build-embeddings command', () => {
it('should complete successfully with no notes to analyze', async () => {
await cleanWorkspace();
const showInfoSpy = jest
.spyOn(vscode.window, 'showInformationMessage')
.mockResolvedValue(undefined);
const result = await vscode.commands.executeCommand<
'complete' | 'cancelled' | 'error'
>(BUILD_EMBEDDINGS_COMMAND.command);
expect(result).toBe('complete');
expect(showInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('No notes found')
);
showInfoSpy.mockRestore();
});
it('should analyze notes and report completion', async () => {
const note1 = await createFile('# Note 1\nContent here', ['note1.md']);
const note2 = await createFile('# Note 2\nMore content', ['note2.md']);
await waitForNoteInFoamWorkspace(note1.uri);
await waitForNoteInFoamWorkspace(note2.uri);
const showInfoSpy = jest
.spyOn(vscode.window, 'showInformationMessage')
.mockResolvedValue(undefined);
const result = await vscode.commands.executeCommand<
'complete' | 'cancelled' | 'error'
>(BUILD_EMBEDDINGS_COMMAND.command);
expect(result).toBe('complete');
expect(showInfoSpy).toHaveBeenCalledWith(
expect.stringMatching(/Analyzed.*2/)
);
showInfoSpy.mockRestore();
await deleteFile(note1.uri);
await deleteFile(note2.uri);
});
it('should return cancelled status when operation is cancelled', async () => {
const note1 = await createFile('# Note 1\nContent', ['note1.md']);
await waitForNoteInFoamWorkspace(note1.uri);
const tokenSource = new vscode.CancellationTokenSource();
const withProgressSpy = jest
.spyOn(vscode.window, 'withProgress')
.mockImplementation(async (options, task) => {
const progress = { report: () => {} };
// Cancel immediately
tokenSource.cancel();
return await task(progress, tokenSource.token);
});
const showInfoSpy = jest
.spyOn(vscode.window, 'showInformationMessage')
.mockResolvedValue(undefined);
const result = await vscode.commands.executeCommand<
'complete' | 'cancelled' | 'error'
>(BUILD_EMBEDDINGS_COMMAND.command);
expect(result).toBe('cancelled');
expect(showInfoSpy).toHaveBeenCalledWith(
expect.stringContaining('cancelled')
);
withProgressSpy.mockRestore();
showInfoSpy.mockRestore();
await deleteFile(note1.uri);
});
});

View File

@@ -0,0 +1,91 @@
import * as vscode from 'vscode';
import { Foam } from '../../../core/model/foam';
import { CancellationError } from '../../../core/services/progress';
import { TaskDeduplicator } from '../../../core/utils/task-deduplicator';
import { FoamWorkspace } from '../../../core/model/workspace';
import { FoamEmbeddings } from '../../../ai/model/embeddings';
export const BUILD_EMBEDDINGS_COMMAND = {
command: 'foam-vscode.build-embeddings',
title: 'Foam: Analyze Notes with AI',
};
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
// Deduplicate concurrent executions
const deduplicator = new TaskDeduplicator<
'complete' | 'cancelled' | 'error'
>();
context.subscriptions.push(
vscode.commands.registerCommand(
BUILD_EMBEDDINGS_COMMAND.command,
async () => {
return await deduplicator.run(
() => buildEmbeddings(foam.workspace, foam.embeddings),
() => {
vscode.window.showInformationMessage(
'Note analysis is already in progress - waiting for it to complete'
);
}
);
}
)
);
}
async function buildEmbeddings(
workspace: FoamWorkspace,
embeddings: FoamEmbeddings
): Promise<'complete' | 'cancelled' | 'error'> {
const notesCount = workspace.list().filter(r => r.type === 'note').length;
if (notesCount === 0) {
vscode.window.showInformationMessage('No notes found in workspace');
return 'complete';
}
// Show progress notification
return await vscode.window.withProgress(
{
location: vscode.ProgressLocation.Window,
title: 'Analyzing notes',
cancellable: true,
},
async (progress, token) => {
try {
await embeddings.update(progressInfo => {
const title = progressInfo.context?.title || 'Processing...';
const increment = (1 / progressInfo.total) * 100;
progress.report({
message: `${progressInfo.current}/${progressInfo.total} - ${title}`,
increment: increment,
});
}, token);
vscode.window.showInformationMessage(
`✓ Analyzed ${embeddings.size()} of ${notesCount} notes`
);
return 'complete';
} catch (error) {
if (error instanceof CancellationError) {
vscode.window.showInformationMessage(
'Analysis cancelled. Run the command again to continue where you left off.'
);
return 'cancelled';
}
const errorMessage =
error instanceof Error ? error.message : 'Unknown error';
vscode.window.showErrorMessage(
`Failed to analyze notes: ${errorMessage}`
);
return 'error';
}
}
);
}

View File

@@ -0,0 +1,99 @@
import * as vscode from 'vscode';
import { Foam } from '../../../core/model/foam';
import { fromVsCodeUri, toVsCodeUri } from '../../../utils/vsc-utils';
import { URI } from '../../../core/model/uri';
import { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';
export const SHOW_SIMILAR_NOTES_COMMAND = {
command: 'foam-vscode.show-similar-notes',
title: 'Foam: Show Similar Notes',
};
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
context.subscriptions.push(
vscode.commands.registerCommand(
SHOW_SIMILAR_NOTES_COMMAND.command,
async () => {
await showSimilarNotes(foam);
}
)
);
}
async function showSimilarNotes(foam: Foam): Promise<void> {
// Get the active editor
const editor = vscode.window.activeTextEditor;
if (!editor) {
vscode.window.showInformationMessage('Please open a note first');
return;
}
// Get the URI of the active document
const uri = fromVsCodeUri(editor.document.uri);
// Check if the resource exists in workspace
const resource = foam.workspace.find(uri);
if (!resource) {
vscode.window.showInformationMessage('This file is not a note');
return;
}
// Ensure embeddings are up-to-date (incremental update)
const status: 'complete' | 'error' | 'cancelled' =
await vscode.commands.executeCommand(BUILD_EMBEDDINGS_COMMAND.command);
if (status !== 'complete') {
return;
}
// Check if embedding exists for this resource
const embedding = foam.embeddings.getEmbedding(uri);
if (!embedding) {
vscode.window.showInformationMessage(
'This note hasn\'t been analyzed yet. Make sure the AI service is running and try the "Analyze Notes with AI" command.'
);
return;
}
// Get similar notes
const similar = foam.embeddings.getSimilar(uri, 10);
if (similar.length === 0) {
vscode.window.showInformationMessage('No similar notes found');
return;
}
// Create quick pick items
const items: vscode.QuickPickItem[] = similar.map(item => {
const resource = foam.workspace.find(item.uri);
const title = resource?.title || item.uri.getBasename();
const similarityPercent = (item.similarity * 100).toFixed(1);
return {
label: `$(file) ${title}`,
description: `${similarityPercent}% similar`,
detail: item.uri.toFsPath(),
uri: item.uri,
} as vscode.QuickPickItem & { uri: URI };
});
// Show quick pick
const selected = await vscode.window.showQuickPick(items, {
placeHolder: 'Select a similar note to open',
matchOnDescription: true,
matchOnDetail: true,
});
if (selected) {
const selectedUri = (selected as any).uri as URI;
const doc = await vscode.workspace.openTextDocument(
toVsCodeUri(selectedUri)
);
await vscode.window.showTextDocument(doc);
}
}

View File

@@ -0,0 +1,138 @@
import * as vscode from 'vscode';
import { Foam } from '../../../core/model/foam';
import { FoamWorkspace } from '../../../core/model/workspace';
import { URI } from '../../../core/model/uri';
import { fromVsCodeUri } from '../../../utils/vsc-utils';
import { BaseTreeProvider } from '../../../features/panels/utils/base-tree-provider';
import { ResourceTreeItem } from '../../../features/panels/utils/tree-view-utils';
import { FoamEmbeddings } from '../../../ai/model/embeddings';
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
const provider = new RelatedNotesTreeDataProvider(
foam.workspace,
foam.embeddings,
context.globalState
);
const treeView = vscode.window.createTreeView('foam-vscode.related-notes', {
treeDataProvider: provider,
showCollapseAll: false,
});
const updateTreeView = async () => {
const activeEditor = vscode.window.activeTextEditor;
provider.target = activeEditor
? fromVsCodeUri(activeEditor.document.uri)
: undefined;
await provider.refresh();
// Update context for conditional viewsWelcome messages
vscode.commands.executeCommand(
'setContext',
'foam.relatedNotes.state',
provider.getState()
);
};
updateTreeView();
context.subscriptions.push(
provider,
treeView,
foam.embeddings.onDidUpdate(() => updateTreeView()),
vscode.window.onDidChangeActiveTextEditor(() => updateTreeView()),
provider.onDidChangeTreeData(() => {
treeView.title = `Related Notes (${provider.nValues})`;
})
);
}
export class RelatedNotesTreeDataProvider extends BaseTreeProvider<vscode.TreeItem> {
public target?: URI = undefined;
public nValues = 0;
private relatedNotes: Array<{ uri: URI; similarity: number }> = [];
private currentNoteHasEmbedding = false;
constructor(
private workspace: FoamWorkspace,
private embeddings: FoamEmbeddings,
public state: vscode.Memento
) {
super();
}
async refresh(): Promise<void> {
const uri = this.target;
// Clear if no target or target is not a note
if (!uri) {
this.relatedNotes = [];
this.nValues = 0;
this.currentNoteHasEmbedding = false;
super.refresh();
return;
}
const resource = this.workspace.find(uri);
if (!resource || resource.type !== 'note') {
this.relatedNotes = [];
this.nValues = 0;
this.currentNoteHasEmbedding = false;
super.refresh();
return;
}
// Check if current note has an embedding
this.currentNoteHasEmbedding = this.embeddings.getEmbedding(uri) !== null;
// Get similar notes (user can click "Build Embeddings" button if needed)
const similar = this.embeddings.getSimilar(uri, 10);
this.relatedNotes = similar.filter(n => n.similarity > 0.6);
this.nValues = this.relatedNotes.length;
super.refresh();
}
async getChildren(item?: vscode.TreeItem): Promise<vscode.TreeItem[]> {
if (item) {
return [];
}
// If no related notes found, show appropriate message in viewsWelcome
// The empty array will trigger the viewsWelcome content
if (this.relatedNotes.length === 0) {
return [];
}
return this.relatedNotes
.map(({ uri, similarity }) => {
const resource = this.workspace.find(uri);
if (!resource) {
return null;
}
const item = new ResourceTreeItem(resource, this.workspace);
// Show similarity score as percentage in description
item.description = `${Math.round(similarity * 100)}%`;
return item;
})
.filter(item => item !== null) as ResourceTreeItem[];
}
/**
* Returns the current state of the related notes panel
*/
public getState(): 'no-note' | 'no-embedding' | 'ready' {
if (!this.target) {
return 'no-note';
}
if (!this.currentNoteHasEmbedding) {
return 'no-embedding';
}
return 'ready';
}
}

View File

@@ -1,14 +1,9 @@
import { Resource, ResourceLink } from '../model/note';
import { URI } from '../model/uri';
import { Range } from '../model/range';
import { FoamWorkspace } from '../model/workspace';
import { isNone } from '../utils';
import { MarkdownLink } from '../services/markdown-link';
export interface LinkReplace {
newText: string;
range: Range /* old range */;
}
import { TextEdit } from '../services/text-edit';
/**
* convert a link based on its workspace and the note containing it.
@@ -27,7 +22,7 @@ export function convertLinkFormat(
targetFormat: 'wikilink' | 'link',
workspace: FoamWorkspace,
note: Resource | URI
): LinkReplace {
): TextEdit {
const resource = note instanceof URI ? workspace.find(note) : note;
const targetUri = workspace.resolveLink(resource, link);
/* If it's already the target format or a placeholder, no transformation happens */

View File

@@ -1,209 +1,13 @@
import { generateLinkReferences } from '.';
import { TEST_DATA_DIR } from '../../test/test-utils';
import { MarkdownResourceProvider } from '../services/markdown-provider';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { FoamWorkspace } from '../model/workspace';
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import { Logger } from '../utils/log';
import fs from 'fs';
import { URI } from '../model/uri';
import { EOL } from 'os';
import { createMarkdownParser } from '../services/markdown-parser';
import { FileDataStore } from '../../test/test-datastore';
import { TextEdit } from '../services/text-edit';
Logger.setLevel('error');
describe('generateLinkReferences', () => {
let _workspace: FoamWorkspace;
// TODO slug must be reserved for actual slugs, not file names
const findBySlug = (slug: string): Resource => {
return _workspace
.list()
.find(res => res.uri.getName() === slug) as Resource;
};
beforeAll(async () => {
/** Use fs for reading files in units where vscode.workspace is unavailable */
const readFile = async (uri: URI) =>
(await fs.promises.readFile(uri.toFsPath())).toString();
const dataStore = new FileDataStore(
readFile,
TEST_DATA_DIR.joinPath('__scaffold__').toFsPath()
);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
_workspace = await FoamWorkspace.fromProviders([mdProvider], dataStore);
});
it('initialised test graph correctly', () => {
expect(_workspace.list().length).toEqual(11);
});
it('should add link references to a file that does not have them', async () => {
const note = findBySlug('index');
const expected = {
newText: textForNote(
`
[//begin]: # "Autogenerated link references for markdown compatibility"
[first-document]: first-document "First Document"
[second-document]: second-document "Second Document"
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: Range.create(9, 0, 9, 0),
};
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should remove link definitions from a file that has them, if no links are present', async () => {
const note = findBySlug('second-document');
const expected = {
newText: '',
range: Range.create(6, 0, 8, 42),
};
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should update link definitions if they are present but changed', async () => {
const note = findBySlug('first-document');
const expected = {
newText: textForNote(
`[//begin]: # "Autogenerated link references for markdown compatibility"
[file-without-title]: file-without-title "file-without-title"
[//end]: # "Autogenerated link references"`
),
range: Range.create(8, 0, 10, 42),
};
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should not cause any changes if link reference definitions were up to date', async () => {
const note = findBySlug('third-document');
const expected = null;
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
it('should put links with spaces in angel brackets', async () => {
const note = findBySlug('angel-reference');
const expected = {
newText: textForNote(
`
[//begin]: # "Autogenerated link references for markdown compatibility"
[Note being referred as angel]: <Note being referred as angel> "Note being referred as angel"
[//end]: # "Autogenerated link references"`
),
range: Range.create(3, 0, 3, 0),
};
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual!.range.start).toEqual(expected.range.start);
expect(actual!.range.end).toEqual(expected.range.end);
expect(actual!.newText).toEqual(expected.newText);
});
it('should not remove explicitly entered link references', async () => {
const note = findBySlug('file-with-explicit-link-references');
const expected = null;
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
it('should not remove explicitly entered link references and have an implicit link', async () => {
const note = findBySlug('file-with-explicit-and-implicit-link-references');
const expected = {
newText: textForNote(
`[//begin]: # "Autogenerated link references for markdown compatibility"
[first-document]: first-document "First Document"
[//end]: # "Autogenerated link references"`
),
range: Range.create(8, 0, 10, 42),
};
const noteText = await _workspace.readAsMarkdown(note.uri);
const noteEol = EOL;
const actual = await generateLinkReferences(
note,
noteText,
noteEol,
_workspace,
false
);
expect(actual).toEqual(expected);
});
});
/**
* Will adjust a text line separator to match
* what is used by the note
@@ -216,3 +20,347 @@ function textForNote(text: string): string {
const eol = EOL;
return text.split('\n').join(eol);
}
describe('generateLinkReferences', () => {
const parser = createMarkdownParser();
interface TestCase {
case: string;
input: string;
expected: string;
}
const testCases: TestCase[] = [
{
case: 'should add link references for wikilinks present in note',
input: `
# Index
[[doc1]] [[doc2]] [[file-without-title]]
`,
expected: `
# Index
[[doc1]] [[doc2]] [[file-without-title]]
[doc1]: doc1 "First"
[doc2]: doc2 "Second"
[file-without-title]: file-without-title "file-without-title"
`,
},
{
case: '#1558 - should keep a blank line before link references',
input: `
# Test
[[doc1]]
[[doc2]]
`,
expected: `
# Test
[[doc1]]
[[doc2]]
[doc1]: doc1 "First"
[doc2]: doc2 "Second"
`,
},
{
case: 'should remove obsolete link definitions',
input: `
# Document
Some content here.
[doc1]: doc1 "First"
`,
expected: `
# Document
Some content here.
`,
},
{
case: 'should add and remove link definitions as needed',
input: `
# First Document
Here's some [unrelated] content.
[unrelated]: http://unrelated.com 'This link should not be changed'
[[file-without-title]]
[doc2]: doc2 'Second Document'
`,
expected: `
# First Document
Here's some [unrelated] content.
[unrelated]: http://unrelated.com 'This link should not be changed'
[[file-without-title]]
[file-without-title]: file-without-title "file-without-title"
`,
},
{
case: 'should not change correct link references',
input: `
# Third Document
All the link references are correct in this file.
[[doc1]]
[[doc2]]
[doc1]: doc1 "First"
[doc2]: doc2 "Second"
`,
expected: `
# Third Document
All the link references are correct in this file.
[[doc1]]
[[doc2]]
[doc1]: doc1 "First"
[doc2]: doc2 "Second"
`,
},
{
case: 'should put links with spaces in angel brackets',
input: `
# Angel reference
[[Angel note]]
`,
expected: `
# Angel reference
[[Angel note]]
[Angel note]: <Angel note> "Angel note"
`,
},
{
case: 'should not remove explicitly entered link references',
input: `
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference]
[^footerlink]: https://foambubble.github.io/
[linkreference]: https://foambubble.github.io/
`,
expected: `
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference]
[^footerlink]: https://foambubble.github.io/
[linkreference]: https://foambubble.github.io/
`,
},
{
case: 'should not change explicitly entered link references',
input: `
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference].
I also want a [[doc1]].
[^footerlink]: https://foambubble.github.io/
[linkreference]: https://foambubble.github.io/
`,
expected: `
# File with explicit link references
A Bug [^footerlink]. Here is [Another link][linkreference].
I also want a [[doc1]].
[^footerlink]: https://foambubble.github.io/
[linkreference]: https://foambubble.github.io/
[doc1]: doc1 "First"
`,
},
{
case: 'should handle empty file with no wikilinks and no definitions',
input: `
# Empty Document
Just some text without any links.
`,
expected: `
# Empty Document
Just some text without any links.
`,
},
{
case: 'should handle wikilinks with aliases',
input: `
# Document with aliases
[[doc1|Custom Alias]] and [[doc2|Another Alias]]
`,
expected: `
# Document with aliases
[[doc1|Custom Alias]] and [[doc2|Another Alias]]
[doc1|Custom Alias]: doc1 "First"
[doc2|Another Alias]: doc2 "Second"
`,
},
{
case: 'should generate only one definition for multiple references to the same link',
input: `
# Multiple references
First mention: [[doc1]]
Second mention: [[doc1]]
Third mention: [[doc1]]
`,
expected: `
# Multiple references
First mention: [[doc1]]
Second mention: [[doc1]]
Third mention: [[doc1]]
[doc1]: doc1 "First"
`,
},
{
case: 'should handle link definitions in the middle of content',
input: `
# Document
[[doc1]]
[doc1]: doc1 "First"
Some more content here.
[[doc2]]
`,
expected: `
# Document
[[doc1]]
[doc1]: doc1 "First"
Some more content here.
[[doc2]]
[doc2]: doc2 "Second"
`,
},
{
case: 'should handle orphaned wikilinks without corresponding notes',
input: `
# Document with broken links
[[doc1]] [[nonexistent]] [[another-missing]]
`,
expected: `
# Document with broken links
[[doc1]] [[nonexistent]] [[another-missing]]
[doc1]: doc1 "First"
`,
},
{
case: 'should handle file with only blank lines at end',
input: `
`,
expected: `
`,
},
{
case: 'should handle empty files',
input: '',
expected: '',
},
{
case: 'should handle link definitions with different quote styles',
input: `
# Mixed quotes
[[doc1]] [[doc2]]
[doc1]: doc1 'First'
[doc2]: doc2 "Second"
`,
expected: `
# Mixed quotes
[[doc1]] [[doc2]]
[doc1]: doc1 'First'
[doc2]: doc2 "Second"
`,
},
// TODO
// {
// case: 'should append new link references to existing ones without blank lines',
// input: `
// [[doc1]] [[doc2]]
// [doc1]: doc1 "First"
// `,
// expected: `
// [[doc1]] [[doc2]]
// [doc1]: doc1 "First"
// [doc2]: doc2 "Second"
// `,
// },
];
testCases.forEach(testCase => {
// eslint-disable-next-line jest/valid-title
it(testCase.case, async () => {
const workspace = createTestWorkspace([URI.file('/')]);
const workspaceNotes = [
{ uri: '/doc1.md', title: 'First' },
{ uri: '/doc2.md', title: 'Second' },
{ uri: '/file-without-title.md', title: 'file-without-title' },
{ uri: '/Angel note.md', title: 'Angel note' },
];
workspaceNotes.forEach(note => {
workspace.set(createTestNote({ uri: note.uri, title: note.title }));
});
const noteText = testCase.input;
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
const actual = await generateLinkReferences(
note,
noteText,
EOL,
workspace,
false
);
const updated = TextEdit.apply(noteText, actual);
expect(updated).toBe(textForNote(testCase.expected));
});
});
});

View File

@@ -3,78 +3,77 @@ import { Range } from '../model/range';
import { createMarkdownReferences } from '../services/markdown-provider';
import { FoamWorkspace } from '../model/workspace';
import { TextEdit } from '../services/text-edit';
import { Position } from '../model/position';
import { getLinkDefinitions } from '../services/markdown-parser';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
export const generateLinkReferences = async (
note: Resource,
text: string,
currentNoteText: string,
eol: string,
workspace: FoamWorkspace,
includeExtensions: boolean
): Promise<TextEdit | null> => {
): Promise<TextEdit[]> => {
if (!note) {
return null;
return [];
}
const newWikilinkDefinitions = createMarkdownReferences(
const lines = currentNoteText.split(eol);
const nLines = lines.length;
const updatedWikilinkDefinitions = createMarkdownReferences(
workspace,
note,
includeExtensions
);
const beginDelimiterDef = note.definitions.find(
({ label }) => label === '//begin'
const existingWikilinkDefinitions = getLinkDefinitions(currentNoteText);
const toAddWikilinkDefinitions = updatedWikilinkDefinitions.filter(
newDef =>
!existingWikilinkDefinitions.some(
existingDef => existingDef.label === newDef.label
)
);
const endDelimiterDef = note.definitions.find(
({ label }) => label === '//end'
const toRemovedWikilinkDefinitions = existingWikilinkDefinitions.filter(
existingDef =>
!updatedWikilinkDefinitions.some(
newDef => newDef.label === existingDef.label
)
);
const lines = text.split(eol);
const edits: TextEdit[] = [];
const targetRange =
beginDelimiterDef && endDelimiterDef
? Range.createFromPosition(
beginDelimiterDef.range.start,
endDelimiterDef.range.end
)
: Range.create(
lines.length - 1,
lines[lines.length - 1].length,
lines.length - 1,
lines[lines.length - 1].length
);
// Remove old definitions
for (const def of toRemovedWikilinkDefinitions) {
edits.push({ range: def.range, newText: '' });
}
const newReferences =
newWikilinkDefinitions.length === 0
? ''
: [
LINK_REFERENCE_DEFINITION_HEADER,
...newWikilinkDefinitions.map(NoteLinkDefinition.format),
LINK_REFERENCE_DEFINITION_FOOTER,
].join(eol);
// Add new definitions
if (toAddWikilinkDefinitions.length > 0) {
// find the last non-empty line to append the definitions after it
const lastLineIndex = nLines - 1;
let insertLineIndex = lastLineIndex;
while (insertLineIndex > 0 && lines[insertLineIndex].trim() === '') {
insertLineIndex--;
}
// check if the new references match the existing references
const existingReferences = lines
.slice(targetRange.start.line, targetRange.end.line + 1)
.join(eol);
const definitions = toAddWikilinkDefinitions.map(def =>
NoteLinkDefinition.format(def)
);
const text = eol + eol + definitions.join(eol) + eol;
// adjust padding based on whether there are existing definitions
// and, if not, whether we are on an empty line at the end of the file
const padding =
newWikilinkDefinitions.length === 0 || // no definitions
!Position.isEqual(targetRange.start, targetRange.end) // replace existing definitions
? ''
: targetRange.start.character > 0 // not an empty line
? `${eol}${eol}`
: eol;
edits.push({
range: Range.create(
insertLineIndex,
lines[insertLineIndex].length,
lastLineIndex,
lines[lastLineIndex].length
),
newText: text,
});
}
return existingReferences === newReferences
? null
: {
newText: `${padding}${newReferences}`,
range: targetRange,
};
return edits;
};

View File

@@ -5,6 +5,10 @@ import { FoamGraph } from './graph';
import { ResourceParser } from './note';
import { ResourceProvider } from './provider';
import { FoamTags } from './tags';
import { FoamEmbeddings } from '../../ai/model/embeddings';
import { InMemoryEmbeddingCache } from '../../ai/model/in-memory-embedding-cache';
import { EmbeddingProvider } from '../../ai/services/embedding-provider';
import { NoOpEmbeddingProvider } from '../../ai/services/noop-embedding-provider';
import { Logger, withTiming, withTimingAsync } from '../utils/log';
export interface Services {
@@ -18,6 +22,7 @@ export interface Foam extends IDisposable {
workspace: FoamWorkspace;
graph: FoamGraph;
tags: FoamTags;
embeddings: FoamEmbeddings;
}
export const bootstrap = async (
@@ -26,7 +31,8 @@ export const bootstrap = async (
dataStore: IDataStore,
parser: ResourceParser,
initialProviders: ResourceProvider[],
defaultExtension: string = '.md'
defaultExtension: string = '.md',
embeddingProvider?: EmbeddingProvider
) => {
const workspace = await withTimingAsync(
() =>
@@ -48,6 +54,22 @@ export const bootstrap = async (
ms => Logger.info(`Tags loaded in ${ms}ms`)
);
embeddingProvider = embeddingProvider ?? new NoOpEmbeddingProvider();
const embeddings = FoamEmbeddings.fromWorkspace(
workspace,
embeddingProvider,
true,
new InMemoryEmbeddingCache()
);
if (await embeddingProvider.isAvailable()) {
Logger.info('Embeddings service initialized');
} else {
Logger.warn(
'Embedding provider not available. Semantic features will be disabled.'
);
}
watcher?.onDidChange(async uri => {
if (matcher.isMatch(uri)) {
await workspace.fetchAndSet(uri);
@@ -67,6 +89,7 @@ export const bootstrap = async (
workspace,
graph,
tags,
embeddings,
services: {
parser,
dataStore,
@@ -75,6 +98,7 @@ export const bootstrap = async (
dispose: () => {
workspace.dispose();
graph.dispose();
embeddings.dispose();
},
};

View File

@@ -146,7 +146,6 @@ describe('Graph', () => {
});
const noteB = createTestNote({
uri: '/somewhere/page-b.md',
text: '## Section 1\n\n## Section 2',
});
const ws = createTestWorkspace().set(noteA).set(noteB);
const graph = FoamGraph.fromWorkspace(ws);
@@ -264,15 +263,10 @@ describe('Placeholders', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/page-a.md',
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
});
noteA.definitions.push({
label: 'page-b',
url: './page-b.md',
});
noteA.definitions.push({
label: 'page-c',
url: '/path/to/page-c.md',
links: [
{ slug: 'page-b', definitionUrl: './page-b.md' },
{ slug: 'page-c', definitionUrl: '/path/to/page-c.md' },
],
});
ws.set(noteA).set(
createTestNote({ uri: '/different/location/for/note-b.md' })

View File

@@ -6,6 +6,41 @@ export interface ResourceLink {
rawText: string;
range: Range;
isEmbed: boolean;
definition?: string | NoteLinkDefinition;
}
export abstract class ResourceLink {
/**
* Check if this is any kind of reference-style link (resolved or unresolved)
*/
static isReferenceStyleLink(link: ResourceLink): boolean {
return link.definition !== undefined;
}
/**
* Check if this is a reference-style link with unresolved definition
*/
static isUnresolvedReference(
link: ResourceLink
): link is ResourceLink & { definition: string } {
return typeof link.definition === 'string';
}
/**
* Check if this is a reference-style link with resolved definition
*/
static isResolvedReference(
link: ResourceLink
): link is ResourceLink & { definition: NoteLinkDefinition } {
return typeof link.definition === 'object' && link.definition !== null;
}
/**
* Check if this is a regular inline link (not reference-style)
*/
static isRegularLink(link: ResourceLink): boolean {
return link.definition === undefined;
}
}
export interface NoteLinkDefinition {
@@ -26,6 +61,14 @@ export abstract class NoteLinkDefinition {
return text;
}
static isEqual(def1: NoteLinkDefinition, def2: NoteLinkDefinition): boolean {
return (
def1.label === def2.label &&
def1.url === def2.url &&
def1.title === def2.title
);
}
}
export interface Tag {
@@ -52,9 +95,6 @@ export interface Resource {
tags: Tag[];
aliases: Alias[];
links: ResourceLink[];
// TODO to remove
definitions: NoteLinkDefinition[];
}
export interface ResourceParser {

View File

@@ -183,4 +183,41 @@ describe('Identifier computation', () => {
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])
).toEqual('note-a');
});
it('should handle case-sensitive filenames correctly (#1303)', () => {
const workspace = new FoamWorkspace('.md');
const noteUppercase = createTestNote({ uri: '/a/Note.md' });
const noteLowercase = createTestNote({ uri: '/b/note.md' });
workspace.set(noteUppercase).set(noteLowercase);
// Should find exact case matches
expect(workspace.listByIdentifier('Note').length).toEqual(1);
expect(workspace.listByIdentifier('Note')[0].uri.path).toEqual(
'/a/Note.md'
);
expect(workspace.listByIdentifier('note').length).toEqual(1);
expect(workspace.listByIdentifier('note')[0].uri.path).toEqual(
'/b/note.md'
);
// Should not treat them as the same identifier
expect(workspace.listByIdentifier('Note')[0]).not.toEqual(
workspace.listByIdentifier('note')[0]
);
});
it('should generate correct identifiers for case-sensitive files', () => {
const workspace = new FoamWorkspace('.md');
const noteUppercase = createTestNote({ uri: '/a/Note.md' });
const noteLowercase = createTestNote({ uri: '/b/note.md' });
workspace.set(noteUppercase).set(noteLowercase);
// Each should have a unique identifier without directory disambiguation
// since they differ by case, they are not considered conflicting
expect(workspace.getIdentifier(noteUppercase.uri)).toEqual('Note');
expect(workspace.getIdentifier(noteLowercase.uri)).toEqual('note');
});
});

View File

@@ -89,13 +89,12 @@ export class FoamWorkspace implements IDisposable {
public listByIdentifier(identifier: string): Resource[] {
let needle = this.getTrieIdentifier(identifier);
const mdNeedle =
getExtension(normalize(identifier)) !== this.defaultExtension
? this.getTrieIdentifier(identifier + this.defaultExtension)
: undefined;
const resources: Resource[] = [];
let resources: Resource[] = [];
this._resources.find(needle).forEach(elm => resources.push(elm[1]));
@@ -103,6 +102,15 @@ export class FoamWorkspace implements IDisposable {
this._resources.find(mdNeedle).forEach(elm => resources.push(elm[1]));
}
// if multiple resources found, try to filter exact case matches
if (resources.length > 1) {
resources = resources.filter(
r =>
r.uri.getBasename() === identifier ||
r.uri.getBasename() === identifier + this.defaultExtension
);
}
return resources.sort(Resource.sortByPath);
}
@@ -115,7 +123,7 @@ export class FoamWorkspace implements IDisposable {
const amongst = [];
const basename = forResource.getBasename();
this.listByIdentifier(basename).map(res => {
this.listByIdentifier(basename).forEach(res => {
// skip self
if (res.uri.isEqual(forResource)) {
return;

View File

@@ -26,7 +26,6 @@ const asResource = (uri: URI): Resource => {
sections: [],
links: [],
tags: [],
definitions: [],
};
};

View File

@@ -94,8 +94,15 @@ export class FileListBasedMatcher implements IMatcher {
include: string[];
exclude: string[];
constructor(files: URI[], private readonly listFiles: () => Promise<URI[]>) {
constructor(
files: URI[],
private readonly listFiles: () => Promise<URI[]>,
include: string[] = ['**/*'],
exclude: string[] = []
) {
this.files = files.map(f => f.path);
this.include = include;
this.exclude = exclude;
}
match(files: URI[]): URI[] {
@@ -110,9 +117,13 @@ export class FileListBasedMatcher implements IMatcher {
this.files = (await this.listFiles()).map(f => f.path);
}
static async createFromListFn(listFiles: () => Promise<URI[]>) {
static async createFromListFn(
listFiles: () => Promise<URI[]>,
include: string[] = ['**/*'],
exclude: string[] = []
) {
const files = await listFiles();
return new FileListBasedMatcher(files, listFiles);
return new FileListBasedMatcher(files, listFiles, include, exclude);
}
}

View File

@@ -523,4 +523,101 @@ describe('MarkdownLink', () => {
expect(wikilinkEdit.range).toEqual(wikilink.range);
});
});
describe('parse links with resolved definitions', () => {
it('should parse wikilink with resolved definition - target and section from definition, alias from rawText', () => {
const link: ResourceLink = {
type: 'wikilink',
rawText: '[[my-note|Custom Display Text]]',
range: Range.create(0, 0),
isEmbed: false,
definition: {
label: 'my-note',
url: './docs/document.md#introduction',
title: 'Document Title',
},
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url (base)
expect(parsed.section).toEqual('introduction'); // From definition.url (fragment)
expect(parsed.alias).toEqual('Custom Display Text'); // From rawText
});
it('should parse reference-style link with resolved definition - target and section from definition, alias from rawText', () => {
const link: ResourceLink = {
type: 'link',
rawText: '[Click here to read][myref]',
range: Range.create(0, 0),
isEmbed: false,
definition: {
label: 'myref',
url: './document.md#section',
title: 'My Document',
},
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('./document.md'); // From definition.url (base)
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
expect(parsed.alias).toEqual('Click here to read'); // From rawText
});
it('should handle wikilink with resolved definition but no section in URL', () => {
const link: ResourceLink = {
type: 'wikilink',
rawText: '[[my-note#ignored-section|Display Text]]',
range: Range.create(0, 0),
isEmbed: false,
definition: {
label: 'my-note',
url: './docs/document.md', // No fragment
title: 'Document Title',
},
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url
expect(parsed.section).toEqual(''); // Empty - no fragment in definition.url
expect(parsed.alias).toEqual('Display Text'); // From rawText
});
it('should handle reference-style link with resolved definition but no alias in rawText', () => {
const link: ResourceLink = {
type: 'link',
rawText: '[text][ref]',
range: Range.create(0, 0),
isEmbed: false,
definition: {
label: 'ref',
url: './target.md#section',
title: 'Target',
},
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('./target.md'); // From definition.url (base)
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
expect(parsed.alias).toEqual('text'); // From rawText
});
it('should handle complex URLs in definitions', () => {
const link: ResourceLink = {
type: 'wikilink',
rawText: '[[note|Alias]]',
range: Range.create(0, 0),
isEmbed: false,
definition: {
label: 'note',
url: '../path/to/some file.md#complex section name',
title: 'Title',
},
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('../path/to/some file.md'); // Base path
expect(parsed.section).toEqual('complex section name'); // Fragment with spaces
expect(parsed.alias).toEqual('Alias'); // From rawText
});
});
});

View File

@@ -1,4 +1,5 @@
import { ResourceLink } from '../model/note';
import { URI } from '../model/uri';
import { TextEdit } from './text-edit';
export abstract class MarkdownLink {
@@ -15,6 +16,17 @@ export abstract class MarkdownLink {
const [, target, section, alias] = this.wikilinkRegex.exec(
link.rawText
);
// For wikilinks with resolved definitions, parse target and section from definition URL
if (ResourceLink.isResolvedReference(link)) {
const definitionUri = URI.parse(link.definition.url, 'tmp');
return {
target: definitionUri.path, // Base path from definition
section: definitionUri.fragment, // Fragment from definition
alias: alias ?? '', // Alias from rawText
};
}
return {
target: target?.replace(/\\/g, '') ?? '',
section: section ?? '',
@@ -22,9 +34,34 @@ export abstract class MarkdownLink {
};
}
if (link.type === 'link') {
const [, alias, target, section] = this.directLinkRegex.exec(
link.rawText
);
// For reference-style links with resolved definitions, parse target and section from definition URL
if (ResourceLink.isResolvedReference(link)) {
// Extract alias from rawText for reference-style links
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
const alias = referenceMatch ? referenceMatch[1] : '';
// Parse target and section from definition URL
const definitionUri = URI.parse(link.definition.url, 'tmp');
return {
target: definitionUri.path, // Base path from definition
section: definitionUri.fragment, // Fragment from definition
alias: alias, // Alias from rawText
};
}
const match = this.directLinkRegex.exec(link.rawText);
if (!match) {
// This might be a reference-style link that wasn't resolved
// Try to extract just the alias text for reference-style links
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
const alias = referenceMatch ? referenceMatch[1] : '';
return {
target: '',
section: '',
alias: alias,
};
}
const [, alias, target, section] = match;
return {
target: target ?? '',
section: section ?? '',

View File

@@ -3,6 +3,7 @@ import {
getBlockFor,
ParserPlugin,
} from './markdown-parser';
import { NoteLinkDefinition, ResourceLink } from '../model/note';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';
@@ -102,6 +103,17 @@ describe('Markdown parsing', () => {
expect(link.isEmbed).toBeFalsy();
});
it('should set reference to alias for wikilinks with alias', () => {
const note = createNoteFromMarkdown(
'This is a [[target-file|Display Name]] wikilink.'
);
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(ResourceLink.isUnresolvedReference(link)).toBe(true);
expect(link.definition).toEqual('target-file');
});
it('should skip wikilinks in codeblocks', () => {
const noteA = createNoteFromMarkdown(`
this is some text with our [[first-wikilink]].
@@ -131,6 +143,84 @@ this is some text with our [[second-wikilink]].
'[[second-wikilink]]',
]);
});
it('#1545 - should not detect single brackets as links', () => {
const note = createNoteFromMarkdown(`
"She said [winning the award] was her best year."
We use brackets ([ and ]) to surround links.
This is not an easy task.[^1]
[^1]: It would be easier if more papers were well written.
`);
expect(note.links.length).toEqual(0);
});
it('should detect reference-style links', () => {
const note = createNoteFromMarkdown(`
# Test Document
This is a [reference-style link][ref1] and another [link][ref2].
[ref1]: target1.md "Target 1"
[ref2]: target2.md "Target 2"
`);
expect(note.links.length).toEqual(2);
const link1 = note.links[0];
expect(link1.type).toEqual('link');
expect(link1.rawText).toEqual('[reference-style link][ref1]');
expect(ResourceLink.isResolvedReference(link1)).toBe(true);
const definition1 = link1.definition as NoteLinkDefinition;
expect(definition1.label).toEqual('ref1');
expect(definition1.url).toEqual('target1.md');
expect(definition1.title).toEqual('Target 1');
const link2 = note.links[1];
expect(link2.type).toEqual('link');
expect(link2.rawText).toEqual('[link][ref2]');
expect(ResourceLink.isResolvedReference(link2)).toBe(true);
const definition2 = link2.definition as NoteLinkDefinition;
expect(definition2.label).toEqual('ref2');
expect(definition2.url).toEqual('target2.md');
});
it('should handle reference-style links without matching definitions', () => {
const note = createNoteFromMarkdown(`
This is a [reference-style link][missing-ref].
[existing-ref]: target.md "Target"
`);
// Per CommonMark spec, reference links without matching definitions
// should be treated as plain text, not as links
expect(note.links.length).toEqual(0);
});
it('should handle mixed link types', () => {
const note = createNoteFromMarkdown(`
This has [[wikilink]], [inline link](target.md), and [reference link][ref].
[ref]: reference-target.md "Reference Target"
`);
expect(note.links.length).toEqual(3);
expect(note.links[0].type).toEqual('wikilink');
expect(note.links[0].rawText).toEqual('[[wikilink]]');
expect(ResourceLink.isUnresolvedReference(note.links[0])).toBe(true);
expect(note.links[0].definition).toEqual('wikilink');
expect(note.links[1].type).toEqual('link');
expect(note.links[1].rawText).toEqual('[inline link](target.md)');
expect(ResourceLink.isReferenceStyleLink(note.links[1])).toBe(false);
expect(note.links[2].type).toEqual('link');
expect(note.links[2].rawText).toEqual('[reference link][ref]');
expect(ResourceLink.isResolvedReference(note.links[2])).toBe(true);
});
});
describe('Note Title', () => {

View File

@@ -6,7 +6,12 @@ import wikiLinkPlugin from 'remark-wiki-link';
import frontmatterPlugin from 'remark-frontmatter';
import { parse as parseYAML } from 'yaml';
import visit from 'unist-util-visit';
import { NoteLinkDefinition, Resource, ResourceParser } from '../model/note';
import {
NoteLinkDefinition,
Resource,
ResourceLink,
ResourceParser,
} from '../model/note';
import { Position } from '../model/position';
import { Range } from '../model/range';
import { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils';
@@ -41,19 +46,34 @@ export interface ParserCacheEntry {
*/
export type ParserCache = ICache<URI, ParserCacheEntry>;
const parser = unified()
.use(markdownParse, { gfm: true })
.use(frontmatterPlugin, ['yaml'])
.use(wikiLinkPlugin, { aliasDivider: '|' });
export function getLinkDefinitions(markdown: string): NoteLinkDefinition[] {
const definitions: NoteLinkDefinition[] = [];
const tree = parser.parse(markdown);
visit(tree, node => {
if (node.type === 'definition') {
definitions.push({
label: (node as any).label,
url: (node as any).url,
title: (node as any).title,
range: astPositionToFoamRange(node.position!),
});
}
});
return definitions;
}
export function createMarkdownParser(
extraPlugins: ParserPlugin[] = [],
cache?: ParserCache
): ResourceParser {
const parser = unified()
.use(markdownParse, { gfm: true })
.use(frontmatterPlugin, ['yaml'])
.use(wikiLinkPlugin, { aliasDivider: '|' });
const plugins = [
titlePlugin,
wikilinkPlugin,
definitionsPlugin,
tagsPlugin,
aliasesPlugin,
sectionsPlugin,
@@ -89,9 +109,10 @@ export function createMarkdownParser(
tags: [],
aliases: [],
links: [],
definitions: [],
};
const localDefinitions: NoteLinkDefinition[] = [];
for (const plugin of plugins) {
try {
plugin.onWillVisitTree?.(tree, note);
@@ -119,6 +140,15 @@ export function createMarkdownParser(
}
}
if (node.type === 'definition') {
localDefinitions.push({
label: (node as any).label,
url: (node as any).url,
title: (node as any).title,
range: astPositionToFoamRange(node.position!),
});
}
for (const plugin of plugins) {
try {
plugin.visit?.(node, note, markdown);
@@ -134,6 +164,29 @@ export function createMarkdownParser(
handleError(plugin, 'onDidVisitTree', uri, e);
}
}
// Post-processing: Resolve reference identifiers to definitions for all links
note.links.forEach(link => {
if (ResourceLink.isUnresolvedReference(link)) {
// This link has a reference identifier (from linkReference or wikilink)
const referenceId = link.definition;
const definition = localDefinitions.find(
def => def.label === referenceId
);
// Set definition to definition object if found, otherwise keep as string
(link as any).definition = definition || referenceId;
}
});
// For type: 'link', keep only if:
// - It's a direct link [text](url) - no definition field
// - It's a resolved reference - definition is an object
note.links = note.links.filter(
link =>
link.type === 'wikilink' || !ResourceLink.isUnresolvedReference(link)
);
Logger.debug('Result:', note);
return note;
},
@@ -359,6 +412,7 @@ const wikilinkPlugin: ParserPlugin = {
rawText: literalContent,
range,
isEmbed,
definition: (node as any).value,
});
}
if (node.type === 'link' || node.type === 'image') {
@@ -378,24 +432,27 @@ const wikilinkPlugin: ParserPlugin = {
isEmbed: literalContent.startsWith('!'),
});
}
},
};
if (node.type === 'linkReference') {
const literalContent = noteSource.substring(
node.position!.start.offset!,
node.position!.end.offset!
);
const definitionsPlugin: ParserPlugin = {
name: 'definitions',
visit: (node, note) => {
if (node.type === 'definition') {
note.definitions.push({
label: (node as any).label,
url: (node as any).url,
title: (node as any).title,
const identifier = (node as any).identifier;
note.links.push({
type: 'link',
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
isEmbed: false,
// Store reference identifier temporarily - will be resolved in onDidVisitTree
definition: identifier,
});
}
},
onDidVisitTree: (tree, note) => {
const end = astPointToFoamPosition(tree.position.end);
note.definitions = getFoamDefinitions(note.definitions, end);
// This onDidVisitTree is now handled globally after all plugins have run
// and localDefinitions have been collected.
},
};
@@ -414,31 +471,6 @@ const handleError = (
);
};
function getFoamDefinitions(
defs: NoteLinkDefinition[],
fileEndPoint: Position
): NoteLinkDefinition[] {
let previousLine = fileEndPoint.line;
const foamDefinitions = [];
// walk through each definition in reverse order
// (last one first)
for (const def of defs.reverse()) {
// if this definition is more than 2 lines above the
// previous one below it (or file end), that means we
// have exited the trailing definition block, and should bail
const start = def.range!.start.line;
if (start < previousLine - 2) {
break;
}
foamDefinitions.unshift(def);
previousLine = def.range!.end.line;
}
return foamDefinitions;
}
/**
* Converts the 1-index Point object into the VS Code 0-index Position object
* @param point ast Point (1-indexed)

View File

@@ -97,11 +97,7 @@ describe('Link resolution', () => {
const ws = createTestWorkspace();
const noteA = createTestNote({
uri: '/somewhere/from/page-a.md',
links: [{ slug: 'page-b' }],
});
noteA.definitions.push({
label: 'page-b',
url: '../to/page-b.md',
links: [{ slug: 'page-b', definitionUrl: '../to/page-b.md' }],
});
const noteB = createTestNote({
uri: '/somewhere/to/page-b.md',
@@ -307,7 +303,9 @@ describe('Link resolution', () => {
expect(ws.resolveLink(noteB, noteB.links[0])).toEqual(noteA.uri);
expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);
expect(noteD.links).toEqual([]);
// noteD has malformed URL with unencoded space, which gets treated as
// shortcut reference [note] without definition, now correctly filtered out
expect(noteD.links.length).toEqual(0);
});
describe('Workspace-relative paths (root-path relative)', () => {

View File

@@ -57,15 +57,8 @@ export class MarkdownResourceProvider implements ResourceProvider {
const { target, section } = MarkdownLink.analyzeLink(link);
switch (link.type) {
case 'wikilink': {
let definitionUri = undefined;
for (const def of resource.definitions) {
if (def.label === target) {
definitionUri = def.url;
break;
}
}
if (isSome(definitionUri)) {
const definedUri = resource.uri.resolve(definitionUri);
if (ResourceLink.isResolvedReference(link)) {
const definedUri = resource.uri.resolve(link.definition.url);
targetUri =
workspace.find(definedUri, resource.uri)?.uri ??
URI.placeholder(definedUri.path);
@@ -75,23 +68,33 @@ export class MarkdownResourceProvider implements ResourceProvider {
? resource.uri
: workspace.find(target, resource.uri)?.uri ??
URI.placeholder(target);
if (section) {
targetUri = targetUri.with({ fragment: section });
}
}
if (section) {
targetUri = targetUri.with({ fragment: section });
}
break;
}
case 'link': {
if (ResourceLink.isUnresolvedReference(link)) {
// Reference-style link with unresolved reference - treat as placeholder
targetUri = URI.placeholder(link.definition);
break;
}
// Handle reference-style links first
const targetPath = ResourceLink.isResolvedReference(link)
? link.definition.url
: target;
let path: string;
let foundResource: Resource | null = null;
if (target.startsWith('/')) {
if (targetPath.startsWith('/')) {
// Handle workspace-relative paths (root-path relative)
if (this.workspaceRoots.length > 0) {
// Try to resolve against each workspace root
for (const workspaceRoot of this.workspaceRoots) {
const candidatePath = target.substring(1); // Remove leading '/'
const candidatePath = targetPath.substring(1); // Remove leading '/'
const absolutePath = workspaceRoot.joinPath(candidatePath);
const found = workspace.find(absolutePath);
if (found) {
@@ -103,7 +106,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
if (!foundResource) {
// Not found in any workspace root, create placeholder relative to first workspace root
const firstRoot = this.workspaceRoots[0];
const candidatePath = target.substring(1);
const candidatePath = targetPath.substring(1);
const absolutePath = firstRoot.joinPath(candidatePath);
targetUri = URI.placeholder(absolutePath.path);
} else {
@@ -111,7 +114,7 @@ export class MarkdownResourceProvider implements ResourceProvider {
}
} else {
// No workspace roots provided, fall back to existing behavior
path = target;
path = targetPath;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
@@ -119,9 +122,9 @@ export class MarkdownResourceProvider implements ResourceProvider {
} else {
// Handle relative paths and non-root paths
path =
target.startsWith('./') || target.startsWith('../')
? target
: './' + target;
targetPath.startsWith('./') || targetPath.startsWith('../')
? targetPath
: './' + targetPath;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
@@ -149,8 +152,12 @@ export function createMarkdownReferences(
const resource = source instanceof URI ? workspace.find(source) : source;
const definitions = resource.links
.filter(link => link.type === 'wikilink')
.filter(link => ResourceLink.isReferenceStyleLink(link))
.map(link => {
if (ResourceLink.isResolvedReference(link)) {
return link.definition;
}
const targetUri = workspace.resolveLink(resource, link);
const target = workspace.find(targetUri);
if (isNone(target)) {

View File

@@ -0,0 +1,34 @@
/**
* Generic progress information for long-running operations
*/
export interface Progress<T = unknown> {
/** Current item being processed (1-indexed) */
current: number;
/** Total number of items to process */
total: number;
/** Optional context data about the current item */
context?: T;
}
/**
* Callback for reporting progress during operations
*/
export type ProgressCallback<T = unknown> = (progress: Progress<T>) => void;
/**
* Cancellation token for aborting long-running operations
*/
export interface CancellationToken {
/** Whether cancellation has been requested */
readonly isCancellationRequested: boolean;
}
/**
* Exception thrown when an operation is cancelled
*/
export class CancellationError extends Error {
constructor(message: string = 'Operation cancelled') {
super(message);
this.name = 'CancellationError';
}
}

View File

@@ -72,4 +72,32 @@ describe('applyTextEdit', () => {
expect(actual).toBe(expected);
});
it('should apply multiple TextEdits in reverse order (VS Code behavior)', () => {
// This test shows why reverse order is important for range stability
const textEdits = [
// Edit near beginning - would affect later ranges if applied first
{
newText: `[PREFIX] `,
range: Range.create(0, 0, 0, 0),
},
// Edit in middle - range stays valid with reverse order
{
newText: `[MIDDLE] `,
range: Range.create(0, 11, 0, 11),
},
// Edit at end - applied first, doesn't affect other ranges
{
newText: ` [END]`,
range: Range.create(0, 15, 0, 15),
},
];
const text = `this is my text`;
const expected = `[PREFIX] this is my [MIDDLE] text [END]`;
const actual = TextEdit.apply(text, textEdits);
expect(actual).toBe(expected);
});
});

View File

@@ -15,7 +15,28 @@ export abstract class TextEdit {
* @param textEdit
* @returns {string} text with the applied textEdit
*/
public static apply(text: string, textEdit: TextEdit): string {
public static apply(text: string, textEdit: TextEdit): string;
// eslint-disable-next-line no-dupe-class-members
public static apply(text: string, textEdits: TextEdit[]): string;
// eslint-disable-next-line no-dupe-class-members
public static apply(
text: string,
textEditOrEdits: TextEdit | TextEdit[]
): string {
if (Array.isArray(textEditOrEdits)) {
// Apply edits in reverse order (end-to-beginning) to maintain range validity
// This matches VS Code's behavior for TextEdit application
const sortedEdits = [...textEditOrEdits].sort((a, b) =>
Position.compareTo(b.range.start, a.range.start)
);
let result = text;
for (const textEdit of sortedEdits) {
result = this.apply(result, textEdit);
}
return result;
}
const textEdit = textEditOrEdits;
const eol = detectNewline.graceful(text);
const lines = text.split(eol);
const characters = text.split('');

View File

@@ -78,7 +78,7 @@ export async function firstFrom<T>(
* @param functions - The array of functions to execute.
* @returns A generator yielding the results of the functions.
*/
function* lazyExecutor<T>(functions: Array<() => T>): Generator<T> {
export function* lazyExecutor<T>(functions: Array<() => T>): Generator<T> {
for (const fn of functions) {
yield fn();
}

View File

@@ -1,8 +1,8 @@
import { isSome } from './core';
export const HASHTAG_REGEX =
/(?<=^|\s)#([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
/(?<=^|\s)#([0-9]*[\p{L}\p{Extended_Pictographic}/_-](?:[\p{L}\p{Extended_Pictographic}\p{N}/_-]|\uFE0F|\p{Emoji_Modifier})*)/gmu;
export const WORD_REGEX =
/(?<=^|\s)([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
/(?<=^|\s)([0-9]*[\p{L}\p{Extended_Pictographic}/_-](?:[\p{L}\p{Extended_Pictographic}\p{N}/_-]|\uFE0F|\p{Emoji_Modifier})*)/gmu;
export const extractHashtags = (
text: string

View File

@@ -1,6 +1,28 @@
import { asAbsolutePaths } from './path';
import { asAbsolutePaths, fromFsPath } from './path';
describe('path utils', () => {
describe('fromFsPath', () => {
it('should normalize backslashes in relative paths', () => {
const [path] = fromFsPath('areas\\dailies\\2024\\file.md');
expect(path).toBe('areas/dailies/2024/file.md');
});
it('should handle mixed separators in relative paths', () => {
const [path] = fromFsPath('areas/dailies\\2024/file.md');
expect(path).toBe('areas/dailies/2024/file.md');
});
it('should preserve forward slashes in relative paths', () => {
const [path] = fromFsPath('areas/dailies/2024/file.md');
expect(path).toBe('areas/dailies/2024/file.md');
});
it('should normalize backslashes in Windows absolute paths', () => {
const [path] = fromFsPath('C:\\workspace\\file.md');
expect(path).toBe('/C:/workspace/file.md');
});
});
describe('asAbsolutePaths', () => {
it('returns the path if already absolute', () => {
const paths = asAbsolutePaths('/path/to/test', [

View File

@@ -16,13 +16,16 @@ export function fromFsPath(path: string): [string, string] {
let authority: string;
if (isUNCShare(path)) {
[path, authority] = parseUNCShare(path);
path = path.replace(/\\/g, '/');
} else if (hasDrive(path)) {
path = '/' + path[0].toUpperCase() + path.substr(1).replace(/\\/g, '/');
path = '/' + path[0].toUpperCase() + path.substr(1);
} else if (path[0] === '/' && hasDrive(path, 1)) {
// POSIX representation of a Windows path: just normalize drive letter case
path = '/' + path[1].toUpperCase() + path.substr(2);
}
// Always normalize backslashes to forward slashes (filesystem → POSIX)
path = path.replace(/\\/g, '/');
return [path, authority];
}

View File

@@ -0,0 +1,306 @@
import { TaskDeduplicator } from './task-deduplicator';
describe('TaskDeduplicator', () => {
describe('run', () => {
it('should execute a task and return its result', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = jest.fn(async () => 'result');
const result = await deduplicator.run(task);
expect(result).toBe('result');
expect(task).toHaveBeenCalledTimes(1);
});
it('should deduplicate concurrent calls to the same task', async () => {
const deduplicator = new TaskDeduplicator<string>();
let executeCount = 0;
const task = async () => {
executeCount++;
await new Promise(resolve => setTimeout(resolve, 10));
return 'result';
};
// Start multiple concurrent calls
const [result1, result2, result3] = await Promise.all([
deduplicator.run(task),
deduplicator.run(task),
deduplicator.run(task),
]);
// All should get the same result
expect(result1).toBe('result');
expect(result2).toBe('result');
expect(result3).toBe('result');
// Task should only execute once
expect(executeCount).toBe(1);
});
it('should call onDuplicate callback for concurrent calls', async () => {
const deduplicator = new TaskDeduplicator<string>();
const onDuplicate = jest.fn();
const task = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return 'result';
};
// Start concurrent calls
const promise1 = deduplicator.run(task);
const promise2 = deduplicator.run(task, onDuplicate);
const promise3 = deduplicator.run(task, onDuplicate);
await Promise.all([promise1, promise2, promise3]);
// onDuplicate should be called for the 2nd and 3rd calls
expect(onDuplicate).toHaveBeenCalledTimes(2);
});
it('should not call onDuplicate for the first call', async () => {
const deduplicator = new TaskDeduplicator<string>();
const onDuplicate = jest.fn();
const task = jest.fn(async () => 'result');
await deduplicator.run(task, onDuplicate);
expect(onDuplicate).not.toHaveBeenCalled();
});
it('should allow new tasks after previous task completes', async () => {
const deduplicator = new TaskDeduplicator<number>();
let counter = 0;
const task1 = async () => ++counter;
const task2 = async () => ++counter;
const result1 = await deduplicator.run(task1);
const result2 = await deduplicator.run(task2);
expect(result1).toBe(1);
expect(result2).toBe(2);
});
it('should propagate errors from the task', async () => {
const deduplicator = new TaskDeduplicator<string>();
const error = new Error('Task failed');
const task = jest.fn(async () => {
throw error;
});
await expect(deduplicator.run(task)).rejects.toThrow('Task failed');
});
it('should propagate errors to all concurrent callers', async () => {
const deduplicator = new TaskDeduplicator<string>();
const error = new Error('Task failed');
const task = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
throw error;
};
const promise1 = deduplicator.run(task);
const promise2 = deduplicator.run(task);
const promise3 = deduplicator.run(task);
await expect(promise1).rejects.toThrow('Task failed');
await expect(promise2).rejects.toThrow('Task failed');
await expect(promise3).rejects.toThrow('Task failed');
});
it('should clear running task after error', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task1 = jest.fn(async () => {
throw new Error('Task failed');
});
const task2 = jest.fn(async () => 'success');
// First task fails
await expect(deduplicator.run(task1)).rejects.toThrow('Task failed');
// Second task should execute (not deduplicated)
const result = await deduplicator.run(task2);
expect(result).toBe('success');
expect(task1).toHaveBeenCalledTimes(1);
expect(task2).toHaveBeenCalledTimes(1);
});
it('should handle different return types', async () => {
// String
const stringDeduplicator = new TaskDeduplicator<string>();
const stringResult = await stringDeduplicator.run(async () => 'test');
expect(stringResult).toBe('test');
// Number
const numberDeduplicator = new TaskDeduplicator<number>();
const numberResult = await numberDeduplicator.run(async () => 42);
expect(numberResult).toBe(42);
// Object
const objectDeduplicator = new TaskDeduplicator<{ value: string }>();
const objectResult = await objectDeduplicator.run(async () => ({
value: 'test',
}));
expect(objectResult).toEqual({ value: 'test' });
// Union types
type Status = 'complete' | 'cancelled' | 'error';
const statusDeduplicator = new TaskDeduplicator<Status>();
const statusResult = await statusDeduplicator.run(
async () => 'complete' as Status
);
expect(statusResult).toBe('complete');
});
});
describe('isRunning', () => {
it('should return false when no task is running', () => {
const deduplicator = new TaskDeduplicator<string>();
expect(deduplicator.isRunning()).toBe(false);
});
it('should return true when a task is running', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return 'result';
};
const promise = deduplicator.run(task);
expect(deduplicator.isRunning()).toBe(true);
await promise;
});
it('should return false after task completes', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = jest.fn(async () => 'result');
await deduplicator.run(task);
expect(deduplicator.isRunning()).toBe(false);
});
it('should return false after task fails', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = jest.fn(async () => {
throw new Error('Failed');
});
await expect(deduplicator.run(task)).rejects.toThrow('Failed');
expect(deduplicator.isRunning()).toBe(false);
});
});
describe('clear', () => {
it('should clear the running task reference', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return 'result';
};
const promise = deduplicator.run(task);
expect(deduplicator.isRunning()).toBe(true);
deduplicator.clear();
expect(deduplicator.isRunning()).toBe(false);
// Original promise should still complete
await expect(promise).resolves.toBe('result');
});
it('should allow new task after manual clear', async () => {
const deduplicator = new TaskDeduplicator<string>();
let executeCount = 0;
const task = async () => {
executeCount++;
await new Promise(resolve => setTimeout(resolve, 50));
return 'result';
};
// Start first task
const promise1 = deduplicator.run(task);
// Clear while still running
deduplicator.clear();
// Start second task (should not be deduplicated)
const promise2 = deduplicator.run(task);
await Promise.all([promise1, promise2]);
// Both tasks should have executed
expect(executeCount).toBe(2);
});
it('should be safe to call when no task is running', () => {
const deduplicator = new TaskDeduplicator<string>();
expect(() => deduplicator.clear()).not.toThrow();
expect(deduplicator.isRunning()).toBe(false);
});
});
describe('edge cases', () => {
it('should handle tasks that resolve immediately', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = jest.fn(async () => 'immediate');
const result = await deduplicator.run(task);
expect(result).toBe('immediate');
expect(deduplicator.isRunning()).toBe(false);
});
it('should handle tasks that throw synchronously', async () => {
const deduplicator = new TaskDeduplicator<string>();
const task = jest.fn(() => {
throw new Error('Sync error');
});
await expect(deduplicator.run(task as any)).rejects.toThrow('Sync error');
expect(deduplicator.isRunning()).toBe(false);
});
it('should handle null/undefined results', async () => {
const nullDeduplicator = new TaskDeduplicator<null>();
const nullResult = await nullDeduplicator.run(async () => null);
expect(nullResult).toBeNull();
const undefinedDeduplicator = new TaskDeduplicator<undefined>();
const undefinedResult = await undefinedDeduplicator.run(
async () => undefined
);
expect(undefinedResult).toBeUndefined();
});
it('should handle sequential calls with delays between them', async () => {
const deduplicator = new TaskDeduplicator<number>();
let counter = 0;
const task = async () => {
await new Promise(resolve => setTimeout(resolve, 10));
return ++counter;
};
const result1 = await deduplicator.run(task);
await new Promise(resolve => setTimeout(resolve, 20));
const result2 = await deduplicator.run(task);
expect(result1).toBe(1);
expect(result2).toBe(2);
});
});
});

View File

@@ -0,0 +1,67 @@
/**
* A utility class for deduplicating concurrent async operations.
* When multiple calls are made while a task is running, subsequent calls
* will wait for and receive the result of the already-running task instead
* of starting a new one.
*
* @example
* const deduplicator = new TaskDeduplicator<string>();
*
* async function expensiveOperation(input: string): Promise<string> {
* return deduplicator.run(async () => {
* // Expensive work here
* return result;
* });
* }
*
* // Multiple concurrent calls will share the same execution
* const [result1, result2] = await Promise.all([
* expensiveOperation("test"),
* expensiveOperation("test"),
* ]);
* // Only runs once, both get the same result
*/
export class TaskDeduplicator<T> {
private runningTask: Promise<T> | null = null;
/**
* Run a task with deduplication.
* If a task is already running, waits for it to complete and returns its result.
* Otherwise, starts the task and stores its promise for other callers to await.
*
* @param task The async function to execute
* @param onDuplicate Optional callback when a duplicate call is detected
* @returns The result of the task
*/
async run(task: () => Promise<T>, onDuplicate?: () => void): Promise<T> {
// If already running, wait for the existing task
if (this.runningTask) {
onDuplicate?.();
return await this.runningTask;
}
// Start the task and store the promise
this.runningTask = task();
try {
return await this.runningTask;
} finally {
// Clear the task when done
this.runningTask = null;
}
}
/**
* Check if a task is currently running
*/
isRunning(): boolean {
return this.runningTask !== null;
}
/**
* Clear the running task reference (useful for testing or error recovery)
*/
clear(): void {
this.runningTask = null;
}
}

View File

@@ -80,6 +80,25 @@ describe('hashtag extraction', () => {
]);
});
it('supports emoji tags with variant selectors (issue #1536)', () => {
expect(
extractHashtags('#🗃️/37-Education #🔖/37/Learning #🟣HOUSE #🟠MONEY').map(
t => t.label
)
).toEqual(['🗃️/37-Education', '🔖/37/Learning', '🟣HOUSE', '🟠MONEY']);
});
it('supports individual emojis with variant selectors', () => {
// Test each emoji separately to debug
expect(extractHashtags('#🗃️').map(t => t.label)).toEqual(['🗃️']);
expect(extractHashtags('#🔖').map(t => t.label)).toEqual(['🔖']);
});
it('supports emojis that work without variant selector', () => {
// These emojis should work with current implementation
expect(extractHashtags('#📥 #⭐').map(t => t.label)).toEqual(['📥', '⭐']);
});
it('ignores hashes in plain text urls and links', () => {
expect(
extractHashtags(`

View File

@@ -111,6 +111,7 @@ Month: \${FOAM_DATE_MONTH} (name: \${FOAM_DATE_MONTH_NAME}, short: \${FOAM_DATE_
Date: \${FOAM_DATE_DATE}
Day: \${FOAM_DATE_DAY_NAME} (short: \${FOAM_DATE_DAY_NAME_SHORT})
Week: \${FOAM_DATE_WEEK}
Week Year: \${FOAM_DATE_WEEK_YEAR}
Unix: \${FOAM_DATE_SECONDS_UNIX}`,
DAILY_NOTE_TEMPLATE
);
@@ -127,6 +128,7 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
expect(content).toContain('Date: 12');
expect(content).toContain('Day: Sunday (short: Sun)');
expect(content).toContain('Week: 36');
expect(content).toContain('Week Year: 2021');
await deleteFile(template.uri);
await deleteFile(result.uri);

View File

@@ -10,7 +10,8 @@ import { features } from './features';
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
import {
getAttachmentsExtensions,
getIgnoredFilesSetting,
getExcludedFilesSetting,
getIncludeFilesSetting,
getNotesExtensions,
} from './settings';
import { AttachmentResourceProvider } from './core/services/attachment-provider';
@@ -18,6 +19,7 @@ import { VsCodeWatcher } from './services/watcher';
import { createMarkdownParser } from './core/services/markdown-parser';
import VsCodeBasedParserCache from './services/cache';
import { createMatcherAndDataStore } from './services/editor';
import { OllamaEmbeddingProvider } from './ai/providers/ollama/ollama-provider';
export async function activate(context: ExtensionContext) {
const logger = new VsCodeOutputLogger();
@@ -33,14 +35,15 @@ export async function activate(context: ExtensionContext) {
}
// Prepare Foam
const excludes = getIgnoredFilesSetting().map(g => g.toString());
const { matcher, dataStore, excludePatterns } =
await createMatcherAndDataStore(excludes);
const includes = getIncludeFilesSetting().map(g => g.toString());
const excludes = getExcludedFilesSetting().map(g => g.toString());
const { matcher, dataStore, includePatterns, excludePatterns } =
await createMatcherAndDataStore(includes, excludes);
Logger.info('Loading from directories:');
for (const folder of workspace.workspaceFolders) {
Logger.info('- ' + folder.uri.fsPath);
Logger.info(' Include: **/*');
Logger.info(' Include: ' + includePatterns.get(folder.name).join(','));
Logger.info(' Exclude: ' + excludePatterns.get(folder.name).join(','));
}
@@ -69,13 +72,20 @@ export async function activate(context: ExtensionContext) {
attachmentExtConfig
);
// Initialize embedding provider
const aiEnabled = workspace.getConfiguration('foam.experimental').get('ai');
const embeddingProvider = aiEnabled
? new OllamaEmbeddingProvider()
: undefined;
const foamPromise = bootstrap(
matcher,
watcher,
dataStore,
parser,
[markdownProvider, attachmentProvider],
defaultExtension
defaultExtension,
embeddingProvider
);
// Load the features
@@ -98,6 +108,8 @@ export async function activate(context: ExtensionContext) {
if (
[
'foam.files.ignore',
'foam.files.exclude',
'foam.files.include',
'foam.files.attachmentExtensions',
'foam.files.noteExtensions',
'foam.files.defaultNoteExtension',

View File

@@ -1,188 +0,0 @@
import { commands, ExtensionContext, window, workspace, Uri } from 'vscode';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
import { ResourceParser } from '../../core/model/note';
import { IMatcher } from '../../core/services/datastore';
import { convertLinkFormat } from '../../core/janitor';
import { isMdEditor } from '../../services/editor';
type LinkFormat = 'wikilink' | 'link';
enum ConvertOption {
Wikilink2MDlink,
MDlink2Wikilink,
}
interface IConfig {
from: string;
to: string;
}
const Config: { [key in ConvertOption]: IConfig } = {
[ConvertOption.Wikilink2MDlink]: {
from: 'wikilink',
to: 'link',
},
[ConvertOption.MDlink2Wikilink]: {
from: 'link',
to: 'wikilink',
},
};
export default async function activate(
context: ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
/*
commands:
foam-vscode.convert-link-style-inplace
foam-vscode.convert-link-style-incopy
*/
context.subscriptions.push(
commands.registerCommand('foam-vscode.convert-link-style-inplace', () => {
return convertLinkAdapter(
foam.workspace,
foam.services.parser,
foam.services.matcher,
true
);
}),
commands.registerCommand('foam-vscode.convert-link-style-incopy', () => {
return convertLinkAdapter(
foam.workspace,
foam.services.parser,
foam.services.matcher,
false
);
})
);
}
async function convertLinkAdapter(
fWorkspace: FoamWorkspace,
fParser: ResourceParser,
fMatcher: IMatcher,
isInPlace: boolean
) {
const convertOption = await pickConvertStrategy();
if (!convertOption) {
window.showInformationMessage('Convert canceled');
return;
}
if (isInPlace) {
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
} else {
await convertLinkInCopy(fWorkspace, fParser, fMatcher, convertOption);
}
}
async function pickConvertStrategy(): Promise<IConfig | undefined> {
const options = {
'to wikilink': ConvertOption.MDlink2Wikilink,
'to markdown link': ConvertOption.Wikilink2MDlink,
};
return window.showQuickPick(Object.keys(options)).then(name => {
if (name) {
return Config[options[name]];
} else {
return undefined;
}
});
}
/**
* convert links based on its workspace and the note containing it.
* Changes happen in-place
* @param fWorkspace
* @param fParser
* @param fMatcher
* @param convertOption
* @returns void
*/
async function convertLinkInPlace(
fWorkspace: FoamWorkspace,
fParser: ResourceParser,
fMatcher: IMatcher,
convertOption: IConfig
) {
const editor = window.activeTextEditor;
const doc = editor.document;
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
return;
}
// const eol = getEditorEOL();
let text = doc.getText();
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
const textReplaceArr = resource.links
.filter(link => link.type === convertOption.from)
.map(link =>
convertLinkFormat(
link,
convertOption.to as LinkFormat,
fWorkspace,
resource
)
)
/* transform .range property into vscode range */
.map(linkReplace => ({
...linkReplace,
range: toVsCodeRange(linkReplace.range),
}));
/* reorder the array such that the later range comes first */
textReplaceArr.sort((a, b) => b.range.start.compareTo(a.range.start));
await editor.edit(editorBuilder => {
textReplaceArr.forEach(edit => {
editorBuilder.replace(edit.range, edit.newText);
});
});
}
/**
* convert links based on its workspace and the note containing it.
* Changes happen in a copy
* 1. prepare a copy file, and makt it the activeTextEditor
* 2. call to convertLinkInPlace
* @param fWorkspace
* @param fParser
* @param fMatcher
* @param convertOption
* @returns void
*/
async function convertLinkInCopy(
fWorkspace: FoamWorkspace,
fParser: ResourceParser,
fMatcher: IMatcher,
convertOption: IConfig
) {
const editor = window.activeTextEditor;
const doc = editor.document;
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
return;
}
// const eol = getEditorEOL();
let text = doc.getText();
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
const basePath = doc.uri.path.split('/').slice(0, -1).join('/');
const fileUri = doc.uri.with({
path: `${
basePath ? basePath + '/' : ''
}${resource.uri.getName()}.copy${resource.uri.getExtension()}`,
});
const encoder = new TextEncoder();
await workspace.fs.writeFile(fileUri, encoder.encode(text));
await window.showTextDocument(fileUri);
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
}

View File

@@ -0,0 +1,184 @@
/* @unit-ready */
import * as vscode from 'vscode';
import {
cleanWorkspace,
closeEditors,
createFile,
showInEditor,
waitForNoteInFoamWorkspace,
} from '../../test/test-utils-vscode';
import { deleteFile } from '../../services/editor';
import { Logger } from '../../core/utils/log';
import {
CONVERT_WIKILINK_TO_MDLINK,
CONVERT_MDLINK_TO_WIKILINK,
} from './convert-links';
Logger.setLevel('error');
describe('Link Conversion Commands', () => {
beforeEach(async () => {
await cleanWorkspace();
await closeEditors();
});
afterEach(async () => {
await cleanWorkspace();
await closeEditors();
});
describe('foam-vscode.convert-wikilink-to-markdown', () => {
it('should convert wikilink to markdown link', async () => {
const noteA = await createFile('# Note A', ['note-a.md']);
const { uri } = await createFile('Text before [[note-a]] text after');
const { editor } = await showInEditor(uri);
await waitForNoteInFoamWorkspace(noteA.uri);
editor.selection = new vscode.Selection(0, 15, 0, 15);
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
const result = editor.document.getText();
expect(result).toBe('Text before [Note A](note-a.md) text after');
await deleteFile(noteA.uri);
await deleteFile(uri);
});
it('should position cursor at end of converted text', async () => {
const noteA = await createFile('# Note A', ['note-a.md']);
const { uri } = await createFile('Text before [[note-a]] text after');
const { editor } = await showInEditor(uri);
await waitForNoteInFoamWorkspace(noteA.uri);
editor.selection = new vscode.Selection(0, 15, 0, 15);
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
// Cursor should be at the end of the converted markdown link
const expectedPosition = 'Text before [Note A](note-a.md)'.length;
expect(editor.selection.active).toEqual(
new vscode.Position(0, expectedPosition)
);
await deleteFile(noteA.uri);
await deleteFile(uri);
});
it('should show info message when no wikilink at cursor', async () => {
const { uri } = await createFile('Text with no wikilinks');
const { editor } = await showInEditor(uri);
editor.selection = new vscode.Selection(0, 5, 0, 5);
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
expect(showInfoSpy).toHaveBeenCalledWith(
'No wikilink found at cursor position'
);
showInfoSpy.mockRestore();
await deleteFile(uri);
});
it('should show error when resource not found', async () => {
const { uri } = await createFile(
'Text before [[nonexistent-file]] text after'
);
const { editor } = await showInEditor(uri);
editor.selection = new vscode.Selection(0, 20, 0, 20);
const showErrorSpy = jest
.spyOn(vscode.window, 'showErrorMessage')
.mockResolvedValue(undefined);
Logger.setLevel('off');
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
Logger.setLevel('error');
expect(showErrorSpy).toHaveBeenCalled();
showErrorSpy.mockRestore();
await deleteFile(uri);
});
});
describe('foam-vscode.convert-markdown-to-wikilink', () => {
it('should convert markdown link to wikilink', async () => {
const noteA = await createFile('# Note A', ['note-a.md']);
const { uri } = await createFile(
'Text before [Note A](note-a.md) text after'
);
const { editor } = await showInEditor(uri);
await waitForNoteInFoamWorkspace(noteA.uri);
editor.selection = new vscode.Selection(0, 15, 0, 15);
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
const result = editor.document.getText();
expect(result).toBe('Text before [[note-a]] text after');
await deleteFile(uri);
await deleteFile(noteA.uri);
});
it('should position cursor at end of converted text', async () => {
const noteA = await createFile('# Note A', ['note-a.md']);
const { uri } = await createFile(
'Text before [Note A](note-a.md) text after'
);
const { editor } = await showInEditor(uri);
editor.selection = new vscode.Selection(0, 15, 0, 15);
await waitForNoteInFoamWorkspace(noteA.uri);
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
// Cursor should be at the end of the converted wikilink
const expectedPosition = 'Text before [[note-a]]'.length;
expect(editor.document.getText()).toBe(
'Text before [[note-a]] text after'
);
expect(editor.selection.active).toEqual(
new vscode.Position(0, expectedPosition)
);
await deleteFile(uri);
await deleteFile(noteA.uri);
});
it('should show info message when no markdown link at cursor', async () => {
const { uri } = await createFile('Text with no markdown links');
const { editor } = await showInEditor(uri);
editor.selection = new vscode.Selection(0, 5, 0, 5);
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
expect(showInfoSpy).toHaveBeenCalledWith(
'No markdown link found at cursor position'
);
showInfoSpy.mockRestore();
await deleteFile(uri);
});
});
describe('Command registration', () => {
it('should handle no active editor gracefully', async () => {
await closeEditors();
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
expect(true).toBe(true);
});
});
});

View File

@@ -0,0 +1,271 @@
import {
convertWikilinkToMarkdownAtPosition,
convertMarkdownToWikilinkAtPosition,
} from './convert-links';
import { URI } from '../../core/model/uri';
import { Position } from '../../core/model/position';
import { Range } from '../../core/model/range';
import { TextEdit } from '../../core/services/text-edit';
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import { createMarkdownParser } from '../../core/services/markdown-parser';
describe('Link Conversion Functions', () => {
describe('convertWikilinkToMarkdownAtPosition', () => {
it('should convert simple wikilink to markdown link', () => {
const documentText = 'Text before [[note-a]] text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 }; // Inside [[note-a]]
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
);
const parser = createMarkdownParser();
const result = convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[Note A](note-a.md)');
expect(result!.range).toEqual(Range.create(0, 12, 0, 22));
// Check the final result after applying the edit
const finalText = TextEdit.apply(documentText, result!);
expect(finalText).toBe('Text before [Note A](note-a.md) text after');
});
it('should convert wikilink with alias to markdown link', () => {
const documentText = 'Text before [[note-a|Custom Title]] text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
);
const parser = createMarkdownParser();
const result = convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[Custom Title](note-a.md)');
// Check the final result after applying the edit
const finalText = TextEdit.apply(documentText, result!);
expect(finalText).toBe(
'Text before [Custom Title](note-a.md) text after'
);
});
it('should handle subfolders paths correctly', () => {
const documentText = 'Text before [[path/to/note-b]] text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 20 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/path/to/note-b.md', title: 'Note B' })
);
const parser = createMarkdownParser();
const result = convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[Note B](path/to/note-b.md)');
// Check the final result after applying the edit
const finalText = TextEdit.apply(documentText, result!);
expect(finalText).toBe(
'Text before [Note B](path/to/note-b.md) text after'
);
});
it('should handle relative paths correctly', () => {
const documentText = 'Text before [[note-b]] text after';
const documentUri = URI.file('/test/sub1/current.md');
const linkPosition: Position = { line: 0, character: 20 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/sub2/note-b.md', title: 'Note B' })
);
const parser = createMarkdownParser();
const result = convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[Note B](../sub2/note-b.md)');
// Check the final result after applying the edit
const finalText = TextEdit.apply(documentText, result!);
expect(finalText).toBe(
'Text before [Note B](../sub2/note-b.md) text after'
);
});
it('should return null when no wikilink at cursor position', () => {
const documentText = 'Text with no wikilink at cursor';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 5 };
const workspace = createTestWorkspace();
const parser = createMarkdownParser();
const result = convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).toBeNull();
});
it('should throw error when target resource not found', () => {
const documentText = 'Text before [[nonexistent]] text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace(); // Empty workspace
const parser = createMarkdownParser();
expect(() => {
convertWikilinkToMarkdownAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
}).toThrow('Resource "nonexistent" not found');
});
});
describe('convertMarkdownToWikilinkAtPosition', () => {
it('should convert simple markdown link to wikilink', () => {
const documentText = 'Text before [Note A](note-a.md) text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
);
const parser = createMarkdownParser();
const result = convertMarkdownToWikilinkAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[[note-a]]');
expect(result!.range).toEqual(Range.create(0, 12, 0, 31));
});
it('should convert simple markdown link to other folder to wikilink', () => {
const documentText = 'Text before [Note A](docs/note-a.md) text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/docs/note-a.md', title: 'Note A' })
);
const parser = createMarkdownParser();
const result = convertMarkdownToWikilinkAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[[note-a]]');
expect(result!.range).toEqual(Range.create(0, 12, 0, 36));
});
it('should preserve alias when different from title', () => {
const documentText = 'Text before [Custom Title](note-a.md) text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace().set(
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
);
const parser = createMarkdownParser();
const result = convertMarkdownToWikilinkAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).not.toBeNull();
expect(result!.newText).toBe('[[note-a|Custom Title]]');
});
it('should return null when no markdown link at cursor position', () => {
const documentText = 'Text with no markdown link at cursor';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 5 };
const workspace = createTestWorkspace();
const parser = createMarkdownParser();
const result = convertMarkdownToWikilinkAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
expect(result).toBeNull();
});
it('should throw error when target resource not found', () => {
const documentText = 'Text before [Link](nonexistent.md) text after';
const documentUri = URI.file('/test/current.md');
const linkPosition: Position = { line: 0, character: 15 };
const workspace = createTestWorkspace();
const parser = createMarkdownParser();
expect(() => {
convertMarkdownToWikilinkAtPosition(
documentText,
documentUri,
linkPosition,
workspace,
parser
);
}).toThrow('Resource not found: /test/nonexistent.md');
});
});
});

View File

@@ -0,0 +1,247 @@
import * as vscode from 'vscode';
import { Foam } from '../../core/model/foam';
import { Resource, ResourceLink } from '../../core/model/note';
import { MarkdownLink } from '../../core/services/markdown-link';
import { Range } from '../../core/model/range';
import { Position } from '../../core/model/position';
import { URI } from '../../core/model/uri';
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
import { Logger } from '../../core/utils/log';
import { TextEdit } from '../../core/services/text-edit';
export const CONVERT_WIKILINK_TO_MDLINK = {
command: 'foam-vscode.convert-wikilink-to-mdlink',
title: 'Foam: Convert Wikilink to Markdown Link',
};
export const CONVERT_MDLINK_TO_WIKILINK = {
command: 'foam-vscode.convert-mdlink-to-wikilink',
title: 'Foam: Convert Markdown Link to Wikilink',
};
/**
* Pure function to convert a wikilink to markdown link at a specific position
* Returns the TextEdit to apply, or null if no conversion is possible
*/
export function convertWikilinkToMarkdownAtPosition(
documentText: string,
documentUri: URI,
linkPosition: Position,
foamWorkspace: { find: (identifier: string) => Resource | null },
foamParser: { parse: (uri: URI, text: string) => Resource }
): TextEdit | null {
// Parse the document to get all links using Foam's parser
const resource = foamParser.parse(documentUri, documentText);
// Find the link at cursor position
const targetLink: ResourceLink | undefined = resource.links.find(
link =>
link.type === 'wikilink' &&
Range.containsPosition(link.range, linkPosition)
);
if (!targetLink) {
return null;
}
// Parse the link to get target and alias information
const linkInfo = MarkdownLink.analyzeLink(targetLink);
// Find the target resource in the workspace
const targetResource = foamWorkspace.find(linkInfo.target);
if (!targetResource) {
throw new Error(`Resource "${linkInfo.target}" not found`);
}
// Compute relative path from current file to target file
const currentDirectory = documentUri.getDirectory();
const relativePath = targetResource.uri.relativeTo(currentDirectory).path;
const alias = linkInfo.alias ? linkInfo.alias : targetResource.title;
return MarkdownLink.createUpdateLinkEdit(targetLink, {
type: 'link',
target: relativePath,
alias: alias,
});
}
/**
* Pure function to convert a markdown link to wikilink at a specific position
* Returns the TextEdit to apply, or null if no conversion is possible
*/
export function convertMarkdownToWikilinkAtPosition(
documentText: string,
documentUri: URI,
cursorPosition: Position,
foamWorkspace: {
resolveLink: (resource: Resource, link: ResourceLink) => URI;
get: (uri: URI) => Resource | null;
getIdentifier: (uri: URI) => string;
},
foamParser: { parse: (uri: URI, text: string) => Resource }
): TextEdit | null {
// Parse the document to get all links using Foam's parser
const resource = foamParser.parse(documentUri, documentText);
// Find the link at cursor position
const targetLink: ResourceLink | undefined = resource.links.find(
link =>
link.type === 'link' && Range.containsPosition(link.range, cursorPosition)
);
if (!targetLink) {
return null;
}
// Parse the link to get target and alias information
const linkInfo = MarkdownLink.analyzeLink(targetLink);
// Try to resolve the target resource from the link
const targetUri = foamWorkspace.resolveLink(resource, targetLink);
const targetResource = foamWorkspace.get(targetUri);
if (!targetResource) {
throw new Error(`Resource not found: ${targetUri.path}`);
}
// Get the workspace identifier for the target resource
const identifier = foamWorkspace.getIdentifier(targetResource.uri);
return MarkdownLink.createUpdateLinkEdit(targetLink, {
type: 'wikilink',
target: identifier,
alias:
linkInfo.alias && linkInfo.alias !== targetResource.title
? linkInfo.alias
: '',
});
}
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
context.subscriptions.push(
vscode.commands.registerCommand(CONVERT_WIKILINK_TO_MDLINK.command, () =>
convertWikilinkToMarkdown(foam)
),
vscode.commands.registerCommand(CONVERT_MDLINK_TO_WIKILINK.command, () =>
convertMarkdownToWikilink(foam)
)
);
}
/**
* Convert wikilink at cursor position to markdown link format
*/
export async function convertWikilinkToMarkdown(foam: Foam): Promise<void> {
const activeEditor = vscode.window.activeTextEditor;
if (!activeEditor) {
return;
}
const document = activeEditor.document;
const position = activeEditor.selection.active;
try {
const edit = convertWikilinkToMarkdownAtPosition(
document.getText(),
fromVsCodeUri(document.uri),
{
line: position.line,
character: position.character,
},
foam.workspace,
foam.services.parser
);
if (!edit) {
vscode.window.showInformationMessage(
'No wikilink found at cursor position'
);
return;
}
// Apply the edit to the document
const range = toVsCodeRange(edit.range);
const success = await activeEditor.edit(editBuilder => {
editBuilder.replace(range, edit.newText);
});
// Position cursor at the end of the updated text
if (success) {
const newEndPosition = new vscode.Position(
range.start.line,
range.start.character + edit.newText.length
);
activeEditor.selection = new vscode.Selection(
newEndPosition,
newEndPosition
);
}
} catch (error) {
Logger.error('Failed to convert wikilink to markdown link', error);
vscode.window.showErrorMessage(
`Failed to convert wikilink to markdown link: ${error.message}`
);
}
}
/**
* Convert markdown link at cursor position to wikilink format
*/
export async function convertMarkdownToWikilink(foam: Foam): Promise<void> {
const activeEditor = vscode.window.activeTextEditor;
if (!activeEditor) {
return;
}
const document = activeEditor.document;
const position = activeEditor.selection.active;
try {
const edit = convertMarkdownToWikilinkAtPosition(
document.getText(),
fromVsCodeUri(document.uri),
{
line: position.line,
character: position.character,
},
foam.workspace,
foam.services.parser
);
if (!edit) {
vscode.window.showInformationMessage(
'No markdown link found at cursor position'
);
return;
}
// Apply the edit to the document
const range = toVsCodeRange(edit.range);
const success = await activeEditor.edit(editBuilder => {
editBuilder.replace(range, edit.newText);
});
// Position cursor at the end of the updated text
if (success) {
const newEndPosition = new vscode.Position(
range.start.line,
range.start.character + edit.newText.length
);
activeEditor.selection = new vscode.Selection(
newEndPosition,
newEndPosition
);
}
} catch (error) {
Logger.error('Failed to convert markdown link to wikilink', error);
vscode.window.showErrorMessage(
`Failed to convert markdown link to wikilink: ${error.message}`
);
}
}

View File

@@ -1,5 +1,5 @@
/* @unit-ready */
import { env, Position, Selection, commands } from 'vscode';
import { env, Selection, commands } from 'vscode';
import { createFile, showInEditor } from '../../test/test-utils-vscode';
import { removeBrackets, toTitleCase } from './copy-without-brackets';

View File

@@ -1,9 +1,13 @@
/* @unit-ready */
import { commands, window, workspace } from 'vscode';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { createFile } from '../../test/test-utils-vscode';
import { cleanWorkspace, createFile } from '../../test/test-utils-vscode';
describe('create-note-from-template command', () => {
beforeAll(async () => {
await cleanWorkspace();
});
afterEach(() => {
jest.clearAllMocks();
});

View File

@@ -1,5 +1,4 @@
import { commands, ExtensionContext } from 'vscode';
import { askUserForTemplate } from '../../services/templates';
export default async function activate(context: ExtensionContext) {
context.subscriptions.push(

View File

@@ -10,6 +10,8 @@ export { default as openResource } from './open-resource';
export { default as updateGraphCommand } from './update-graph';
export { default as updateWikilinksCommand } from './update-wikilinks';
export { default as createNote } from './create-note';
export { default as generateStandaloneNote } from './convert-links-format-in-note';
export { default as searchTagCommand } from './search-tag';
export { default as renameTagCommand } from './rename-tag';
export { default as convertLinksCommand } from './convert-links';
export { default as showSimilarNotesCommand } from '../../ai/vscode/commands/show-similar-notes';
export { default as buildEmbeddingsCommand } from '../../ai/vscode/commands/build-embeddings';

View File

@@ -106,7 +106,7 @@ async function runJanitor(foam: Foam) {
const definitions =
wikilinkSetting === 'off'
? null
? []
: await generateLinkReferences(
note,
noteText,
@@ -114,11 +114,11 @@ async function runJanitor(foam: Foam) {
foam.workspace,
wikilinkSetting === 'withExtensions'
);
if (definitions) {
if (definitions.length > 0) {
updatedDefinitionListCount += 1;
}
if (!heading && !definitions) {
if (!heading && definitions.length === 0) {
return Promise.resolve();
}
@@ -126,7 +126,7 @@ async function runJanitor(foam: Foam) {
// Note: The ordering matters. Definitions need to be inserted
// before heading, since inserting a heading changes line numbers below
let text = noteText;
text = definitions ? TextEdit.apply(text, definitions) : text;
text = definitions.length > 0 ? TextEdit.apply(text, definitions) : text;
text = heading ? TextEdit.apply(text, heading) : text;
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
@@ -148,7 +148,7 @@ async function runJanitor(foam: Foam) {
const heading = await generateHeading(note, noteText, eol);
const definitions =
wikilinkSetting === 'off'
? null
? []
: await generateLinkReferences(
note,
noteText,
@@ -157,19 +157,22 @@ async function runJanitor(foam: Foam) {
wikilinkSetting === 'withExtensions'
);
if (heading || definitions) {
if (heading || definitions.length > 0) {
// Apply Edits
/* eslint-disable */
await editor.edit(editBuilder => {
// Note: The ordering matters. Definitions need to be inserted
// before heading, since inserting a heading changes line numbers below
if (definitions) {
if (definitions.length > 0) {
updatedDefinitionListCount += 1;
const start = definitions.range.start;
const end = definitions.range.end;
// Apply all definition edits
definitions.forEach(definition => {
const start = definition.range.start;
const end = definition.range.end;
const range = Range.createFromPosition(start, end);
editBuilder.replace(toVsCodeRange(range), definitions!.newText);
const range = Range.createFromPosition(start, end);
editBuilder.replace(toVsCodeRange(range), definition.newText);
});
}
if (heading) {

View File

@@ -9,7 +9,7 @@ import {
import { CommandDescriptor } from '../../utils/commands';
import { FoamWorkspace } from '../../core/model/workspace';
import { Resource } from '../../core/model/note';
import { isSome, isNone } from '../../core/utils';
import { isNone } from '../../core/utils';
export default async function activate(
context: vscode.ExtensionContext,

View File

@@ -0,0 +1,202 @@
/* @unit-ready */
import { generateTagSearchPattern } from './search-tag';
describe('search-tag command', () => {
describe('generateTagSearchPattern', () => {
it('generates correct regex pattern for simple tag', () => {
const pattern = generateTagSearchPattern('project-alpha');
expect(pattern).toBe(
'#project-alpha\\b|tags:.*?\\bproject-alpha\\b|^\\s*-\\s+project-alpha\\b\\s*$'
);
});
it('escapes special regex characters in tag label', () => {
const pattern = generateTagSearchPattern('tag.with+special*chars');
expect(pattern).toBe(
'#tag\\.with\\+special\\*chars\\b|tags:.*?\\btag\\.with\\+special\\*chars\\b|^\\s*-\\s+tag\\.with\\+special\\*chars\\b\\s*$'
);
});
it('handles hierarchical tags with forward slashes', () => {
const pattern = generateTagSearchPattern('status/active');
// Forward slashes don't need escaping in regex
expect(pattern).toBe(
'#status/active\\b|tags:.*?\\bstatus/active\\b|^\\s*-\\s+status/active\\b\\s*$'
);
});
it('handles tags with hyphens', () => {
const pattern = generateTagSearchPattern('v1-release');
expect(pattern).toBe(
'#v1-release\\b|tags:.*?\\bv1-release\\b|^\\s*-\\s+v1-release\\b\\s*$'
);
});
it('handles tags with underscores', () => {
const pattern = generateTagSearchPattern('my_tag');
expect(pattern).toBe(
'#my_tag\\b|tags:.*?\\bmy_tag\\b|^\\s*-\\s+my_tag\\b\\s*$'
);
});
it('escapes parentheses', () => {
const pattern = generateTagSearchPattern('tag(with)parens');
expect(pattern).toBe(
'#tag\\(with\\)parens\\b|tags:.*?\\btag\\(with\\)parens\\b|^\\s*-\\s+tag\\(with\\)parens\\b\\s*$'
);
});
it('escapes square brackets', () => {
const pattern = generateTagSearchPattern('tag[with]brackets');
expect(pattern).toBe(
'#tag\\[with\\]brackets\\b|tags:.*?\\btag\\[with\\]brackets\\b|^\\s*-\\s+tag\\[with\\]brackets\\b\\s*$'
);
});
it('escapes curly braces', () => {
const pattern = generateTagSearchPattern('tag{with}braces');
expect(pattern).toBe(
'#tag\\{with\\}braces\\b|tags:.*?\\btag\\{with\\}braces\\b|^\\s*-\\s+tag\\{with\\}braces\\b\\s*$'
);
});
it('escapes question marks', () => {
const pattern = generateTagSearchPattern('tag?');
expect(pattern).toBe(
'#tag\\?\\b|tags:.*?\\btag\\?\\b|^\\s*-\\s+tag\\?\\b\\s*$'
);
});
it('escapes dollar signs', () => {
const pattern = generateTagSearchPattern('tag$');
expect(pattern).toBe(
'#tag\\$\\b|tags:.*?\\btag\\$\\b|^\\s*-\\s+tag\\$\\b\\s*$'
);
});
it('escapes caret', () => {
const pattern = generateTagSearchPattern('^tag');
expect(pattern).toBe(
'#\\^tag\\b|tags:.*?\\b\\^tag\\b|^\\s*-\\s+\\^tag\\b\\s*$'
);
});
it('escapes pipe', () => {
const pattern = generateTagSearchPattern('tag|other');
expect(pattern).toBe(
'#tag\\|other\\b|tags:.*?\\btag\\|other\\b|^\\s*-\\s+tag\\|other\\b\\s*$'
);
});
it('escapes backslash', () => {
const pattern = generateTagSearchPattern('tag\\test');
expect(pattern).toBe(
'#tag\\\\test\\b|tags:.*?\\btag\\\\test\\b|^\\s*-\\s+tag\\\\test\\b\\s*$'
);
});
});
describe('pattern matching verification', () => {
it('pattern should match inline hashtags', () => {
const pattern = generateTagSearchPattern('test-tag');
const regex = new RegExp(pattern);
expect(regex.test('#test-tag')).toBe(true);
expect(regex.test('#test-tag in sentence')).toBe(true);
expect(regex.test('text #test-tag more text')).toBe(true);
});
it('pattern should match YAML array format', () => {
const pattern = generateTagSearchPattern('test-tag');
const regex = new RegExp(pattern);
expect(regex.test('tags: [test-tag, other]')).toBe(true);
expect(regex.test('tags: [test-tag]')).toBe(true);
expect(regex.test('tags: [other, test-tag]')).toBe(true);
expect(regex.test('tags: test-tag')).toBe(true);
});
it('pattern should match YAML list format with tag right after dash', () => {
const pattern = generateTagSearchPattern('test-tag');
const regex = new RegExp(pattern, 'm'); // multiline flag
expect(regex.test(' - test-tag')).toBe(true);
expect(regex.test('- test-tag')).toBe(true);
expect(regex.test(' - test-tag')).toBe(true);
});
it('pattern should NOT match markdown lists with tag not right after dash', () => {
const pattern = generateTagSearchPattern('test-tag');
const regex = new RegExp(pattern, 'm');
// These should NOT match because the tag is not immediately after the dash
expect(regex.test('- This is a test-tag item')).toBe(false);
expect(regex.test('- Some text about test-tag')).toBe(false);
expect(regex.test(' - Another test-tag mention')).toBe(false);
});
it('pattern should NOT match list items with tag followed by other text', () => {
const pattern = generateTagSearchPattern('javascript');
const regex = new RegExp(pattern, 'm');
// These should NOT match because there's text after the tag
expect(regex.test('- javascript is cool')).toBe(false);
expect(regex.test(' - javascript programming')).toBe(false);
expect(regex.test('- javascript: the language')).toBe(false);
});
it('pattern should not match partial words', () => {
const pattern = generateTagSearchPattern('test');
const regex = new RegExp(pattern);
expect(regex.test('#testing')).toBe(false);
expect(regex.test('tags: [testing]')).toBe(false);
expect(regex.test('- testing')).toBe(false);
});
it('pattern should match hierarchical tags', () => {
const pattern = generateTagSearchPattern('project/alpha');
const regex = new RegExp(pattern, 'm');
expect(regex.test('#project/alpha')).toBe(true);
expect(regex.test('tags: [project/alpha]')).toBe(true);
expect(regex.test(' - project/alpha')).toBe(true);
});
it('pattern should match tags with spaces after dash', () => {
const pattern = generateTagSearchPattern('my-tag');
const regex = new RegExp(pattern, 'm');
// Multiple spaces after dash should still match
expect(regex.test(' - my-tag')).toBe(true);
expect(regex.test('- my-tag')).toBe(true);
});
it('pattern should handle real-world YAML examples', () => {
const pattern = generateTagSearchPattern('javascript');
const regex = new RegExp(pattern, 'm');
// YAML front matter examples - should match
const yamlArray = 'tags: [javascript, typescript, react]';
const yamlList = ' - javascript';
const yamlListWithTrailingSpace = ' - javascript ';
const yamlSingle = 'tags: javascript';
const inline = 'Learn #javascript today';
expect(regex.test(yamlArray)).toBe(true);
expect(regex.test(yamlList)).toBe(true);
expect(regex.test(yamlListWithTrailingSpace)).toBe(true);
expect(regex.test(yamlSingle)).toBe(true);
expect(regex.test(inline)).toBe(true);
// False positives - should NOT match
const falsePositive1 = '- Learn javascript programming';
const falsePositive2 = '- javascript is cool';
const falsePositive3 = ' - javascript tutorial';
expect(regex.test(falsePositive1)).toBe(false);
expect(regex.test(falsePositive2)).toBe(false);
expect(regex.test(falsePositive3)).toBe(false);
});
});
});

View File

@@ -7,6 +7,23 @@ export const SEARCH_TAG_COMMAND = {
title: 'Foam: Search Tag',
};
/**
* Generates a regex search pattern that matches both inline tags (#tag) and YAML front matter tags.
*
* @param tagLabel The tag label to search for (without # prefix)
* @returns A regex pattern string that matches the tag in both formats
*/
export function generateTagSearchPattern(tagLabel: string): string {
// Escape special regex characters in tag label
const escapedTag = tagLabel.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
// Pattern matches three cases:
// 1. #tag - inline hashtags with word boundary
// 2. tags: [...tag...] - YAML front matter array format
// 3. ^\s*-\s+tag\s*$ - YAML front matter list format (tag is the only content after dash)
return `#${escapedTag}\\b|tags:.*?\\b${escapedTag}\\b|^\\s*-\\s+${escapedTag}\\b\\s*$`;
}
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
@@ -48,12 +65,15 @@ export default async function activate(
}
}
// Use VS Code's built-in search with the tag pattern
// Generate search pattern that matches both inline and YAML tags
const searchPattern = generateTagSearchPattern(tagLabel);
await vscode.commands.executeCommand('workbench.action.findInFiles', {
query: `#${tagLabel}`,
query: searchPattern,
triggerSearch: true,
matchWholeWord: false,
isCaseSensitive: true,
isRegex: true,
});
}
)

View File

@@ -93,7 +93,7 @@ async function updateWikilinkDefinitions(
}
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
const update = await generateLinkReferences(
const updates = await generateLinkReferences(
resource,
text,
eol,
@@ -101,12 +101,14 @@ async function updateWikilinkDefinitions(
setting === 'withExtensions'
);
if (update) {
if (updates.length > 0) {
await editor.edit(editBuilder => {
const gap = doc.lineAt(update.range.start.line - 1).isEmptyOrWhitespace
? ''
: eol;
editBuilder.replace(toVsCodeRange(update.range), gap + update.newText);
updates.forEach(update => {
const gap = doc.lineAt(update.range.start.line - 1).isEmptyOrWhitespace
? ''
: eol;
editBuilder.replace(toVsCodeRange(update.range), gap + update.newText);
});
});
}
}

View File

@@ -1,3 +1,5 @@
/* @unit-ready */
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
@@ -14,6 +16,7 @@ import {
WikilinkCompletionProvider,
SectionCompletionProvider,
} from './link-completion';
import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';
describe('Link Completion', () => {
const parser = createMarkdownParser([]);
@@ -281,4 +284,220 @@ alias: alias-a
expect(aliasCompletionItem.label).toBe('alias-a');
expect(aliasCompletionItem.insertText).toBe('new-note-with-alias|alias-a');
});
it('should support linkFormat setting - wikilink format (default)', async () => {
const { uri: noteUri, content } = await createFile(`# My Note Title`);
const workspace = createTestWorkspace();
workspace.set(parser.parse(noteUri, content));
const provider = new WikilinkCompletionProvider(
workspace,
FoamGraph.fromWorkspace(workspace)
);
const { uri } = await createFile('[[');
const { doc } = await showInEditor(uri);
await withModifiedFoamConfiguration(
'completion.linkFormat',
'wikilink',
async () => {
await withModifiedFoamConfiguration(
'completion.useAlias',
'never',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
expect(links.items.length).toBe(1);
expect(links.items[0].insertText).toBe(
workspace.getIdentifier(noteUri)
);
}
);
}
);
});
it('should support linkFormat setting - markdown link format', async () => {
const { uri: noteUri, content } = await createFile(`# My Note Title`, [
'my',
'path',
'to',
'test-note.md',
]);
const workspace = createTestWorkspace();
workspace.set(parser.parse(noteUri, content));
const provider = new WikilinkCompletionProvider(
workspace,
FoamGraph.fromWorkspace(workspace)
);
const { uri } = await createFile('[[');
const { doc } = await showInEditor(uri);
await withModifiedFoamConfiguration(
'completion.linkFormat',
'link',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
expect(links.items.length).toBe(1);
const insertText = String(links.items[0].insertText);
// In test environment, the command converts wikilink to markdown after insertion
// The insertText is the wikilink format, conversion happens via command
// So we expect just the identifier (no alias because linkFormat === 'link')
expect(insertText).toBe(workspace.getIdentifier(noteUri));
// Commit characters should be empty when using conversion command
expect(links.items[0].commitCharacters).toEqual([]);
// Verify command is attached for conversion
expect(links.items[0].command).toBeDefined();
expect(links.items[0].command.command).toBe(
CONVERT_WIKILINK_TO_MDLINK.command
);
}
);
});
it('should support linkFormat setting with aliases - markdown format', async () => {
const { uri: noteUri, content } = await createFile(`# My Different Title`, [
'another-note.md',
]);
const workspace = createTestWorkspace();
workspace.set(parser.parse(noteUri, content));
const provider = new WikilinkCompletionProvider(
workspace,
FoamGraph.fromWorkspace(workspace)
);
const { uri } = await createFile('[[');
const { doc } = await showInEditor(uri);
await withModifiedFoamConfiguration(
'completion.linkFormat',
'link',
async () => {
await withModifiedFoamConfiguration(
'completion.useAlias',
'whenPathDiffersFromTitle',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
expect(links.items.length).toBe(1);
const insertText = links.items[0].insertText;
// When linkFormat is 'link', we don't use alias in insertText
// The conversion command handles the title mapping
expect(insertText).toBe(workspace.getIdentifier(noteUri));
expect(links.items[0].commitCharacters).toEqual([]);
// Verify command is attached for conversion
expect(links.items[0].command).toBeDefined();
expect(links.items[0].command.command).toBe(
CONVERT_WIKILINK_TO_MDLINK.command
);
}
);
}
);
});
it('should handle alias completion with markdown link format', async () => {
const { uri, content } = await createFile(
`
---
alias: test-alias
---
[[
`,
['note-with-alias.md']
);
ws.set(parser.parse(uri, content));
const { doc } = await showInEditor(uri);
const provider = new WikilinkCompletionProvider(ws, graph);
await withModifiedFoamConfiguration(
'completion.linkFormat',
'link',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(4, 2)
);
const aliasCompletionItem = links.items.find(
i => i.label === 'test-alias'
);
expect(aliasCompletionItem).not.toBeNull();
expect(aliasCompletionItem.label).toBe('test-alias');
// Alias completions always use pipe syntax in insertText
// The conversion command will convert it to markdown format
expect(aliasCompletionItem.insertText).toBe(
'note-with-alias|test-alias'
);
expect(aliasCompletionItem.commitCharacters).toEqual([]);
// Verify command is attached for conversion
expect(aliasCompletionItem.command).toBeDefined();
expect(aliasCompletionItem.command.command).toBe(
CONVERT_WIKILINK_TO_MDLINK.command
);
}
);
});
it('should ignore linkFormat setting for placeholder completions', async () => {
const { uri } = await createFile('[[');
const { doc } = await showInEditor(uri);
const provider = new WikilinkCompletionProvider(ws, graph);
// Test with wikilink format - should return plain text
await withModifiedFoamConfiguration(
'completion.linkFormat',
'wikilink',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
const placeholderItem = links.items.find(
i => i.label === 'placeholder text'
);
expect(placeholderItem).not.toBeNull();
expect(placeholderItem.insertText).toBe('placeholder text');
}
);
// Test with markdown link format - should also return plain text (ignore format conversion)
await withModifiedFoamConfiguration(
'completion.linkFormat',
'link',
async () => {
const links = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 2)
);
const placeholderItem = links.items.find(
i => i.label === 'placeholder text'
);
expect(placeholderItem).not.toBeNull();
// Placeholders should remain as plain text, not converted to wikilink format
expect(placeholderItem.insertText).toBe('placeholder text');
}
);
});
});

View File

@@ -7,6 +7,7 @@ import { FoamWorkspace } from '../core/model/workspace';
import { getFoamVsCodeConfig } from '../services/config';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { getNoteTooltip, getFoamDocSelectors } from '../services/editor';
import { CONVERT_WIKILINK_TO_MDLINK } from './commands/convert-links';
export const aliasCommitCharacters = ['#'];
export const linkCommitCharacters = ['#', '|'];
@@ -169,15 +170,17 @@ export class WikilinkCompletionProvider
}
const text = requiresAutocomplete[0];
const labelStyle = getCompletionLabelSetting();
const aliasSetting = getCompletionAliasSetting();
const linkFormat = getCompletionLinkFormatSetting();
// Use safe range that VS Code accepts - replace content inside brackets only
const replacementRange = new vscode.Range(
position.line,
position.character - (text.length - 2),
position.line,
position.character
);
const labelStyle = getCompletionLabelSetting();
const aliasSetting = getCompletionAliasSetting();
const resources = this.ws.list().map(resource => {
const resourceIsDocument =
@@ -206,15 +209,22 @@ export class WikilinkCompletionProvider
const useAlias =
resourceIsDocument &&
linkFormat !== 'link' &&
aliasSetting !== 'never' &&
wikilinkRequiresAlias(resource);
wikilinkRequiresAlias(resource, this.ws.defaultExtension);
item.insertText = useAlias
? `${identifier}|${resource.title}`
: identifier;
item.commitCharacters = useAlias ? [] : linkCommitCharacters;
// When using aliases or markdown link format, don't allow commit characters
// since we either have the full text or will convert it
item.commitCharacters =
useAlias || linkFormat === 'link' ? [] : linkCommitCharacters;
item.range = replacementRange;
item.command = COMPLETION_CURSOR_MOVE;
item.command =
linkFormat === 'link'
? CONVERT_WIKILINK_TO_MDLINK
: COMPLETION_CURSOR_MOVE;
return item;
});
const aliases = this.ws.list().flatMap(resource =>
@@ -224,13 +234,27 @@ export class WikilinkCompletionProvider
vscode.CompletionItemKind.Reference,
resource.uri
);
item.insertText = this.ws.getIdentifier(resource.uri) + '|' + a.title;
const identifier = this.ws.getIdentifier(resource.uri);
item.insertText = `${identifier}|${a.title}`;
// When using markdown link format, don't allow commit characters
item.commitCharacters =
linkFormat === 'link' ? [] : aliasCommitCharacters;
item.range = replacementRange;
// If link format is enabled, convert after completion
item.command =
linkFormat === 'link'
? {
command: CONVERT_WIKILINK_TO_MDLINK.command,
title: CONVERT_WIKILINK_TO_MDLINK.title,
}
: COMPLETION_CURSOR_MOVE;
item.detail = `Alias of ${vscode.workspace.asRelativePath(
toVsCodeUri(resource.uri)
)}`;
item.range = replacementRange;
item.command = COMPLETION_CURSOR_MOVE;
item.commitCharacters = aliasCommitCharacters;
return item;
})
);
@@ -293,7 +317,19 @@ function getCompletionAliasSetting() {
return aliasStyle;
}
const normalize = (text: string) => text.toLocaleLowerCase().trim();
function wikilinkRequiresAlias(resource: Resource) {
return normalize(resource.uri.getName()) !== normalize(resource.title);
function getCompletionLinkFormatSetting() {
const linkFormat: 'wikilink' | 'link' = getFoamVsCodeConfig(
'completion.linkFormat'
);
return linkFormat;
}
const normalize = (text: string) => text.toLocaleLowerCase().trim();
function wikilinkRequiresAlias(resource: Resource, defaultExtension: string) {
// Compare filename (without extension) to title
const nameWithoutExt = resource.uri.getName();
const titleWithoutExt = resource.title.endsWith(defaultExtension)
? resource.title.slice(0, -defaultExtension.length)
: resource.title;
return normalize(nameWithoutExt) !== normalize(titleWithoutExt);
}

View File

@@ -0,0 +1,149 @@
import * as vscode from 'vscode';
import { closeEditors, createFile } from '../../test/test-utils-vscode';
import { wait } from '../../test/test-utils';
describe('Graph Panel', () => {
beforeEach(async () => {
await closeEditors();
});
afterEach(async () => {
await closeEditors();
});
it('should create graph beside active editor when panel does not exist', async () => {
const { uri: noteUri } = await createFile('# Note A', ['note-a.md']);
// Open a note in column 1
await vscode.window.showTextDocument(vscode.Uri.file(noteUri.toFsPath()), {
viewColumn: vscode.ViewColumn.One,
});
// Execute show-graph command
await vscode.commands.executeCommand('foam-vscode.show-graph');
// Wait a bit for the webview to be created
await wait(200);
// Find the graph panel - should be beside (to the right of) column 1
const graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
expect(graphPanel).toBeDefined();
// ViewColumn.Beside creates a new column to the right, so it should be > column 1
expect(graphPanel?.group.viewColumn).toBeGreaterThan(vscode.ViewColumn.One);
});
it('should create graph in ViewColumn.One when no active editor', async () => {
// Make sure no editors are open
await closeEditors();
// Execute show-graph command
await vscode.commands.executeCommand('foam-vscode.show-graph');
// Wait a bit for the webview to be created
await wait(200);
// Find the graph panel
const graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
expect(graphPanel).toBeDefined();
expect(graphPanel?.group.viewColumn).toBe(vscode.ViewColumn.One);
});
it('should reveal existing graph panel without moving it', async () => {
const { uri: noteUri } = await createFile('# Note A', ['note-a.md']);
// Open a note in column 1
await vscode.window.showTextDocument(vscode.Uri.file(noteUri.toFsPath()), {
viewColumn: vscode.ViewColumn.One,
});
// Create graph (should be beside column 1, so in column 2)
await vscode.commands.executeCommand('foam-vscode.show-graph');
await wait(200);
let graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
expect(graphPanel).toBeDefined();
const originalGraphColumn = graphPanel?.group.viewColumn;
// Open another note in column 1 (return to first column)
const { uri: note2Uri } = await createFile('# Note B', ['note-b.md']);
await vscode.window.showTextDocument(vscode.Uri.file(note2Uri.toFsPath()), {
viewColumn: vscode.ViewColumn.One,
preview: false,
});
// Focus should be on note in column 1
expect(vscode.window.activeTextEditor?.viewColumn).toBe(
vscode.ViewColumn.One
);
// Show graph again
await vscode.commands.executeCommand('foam-vscode.show-graph');
await wait(200);
// Find the graph panel - it should still be in its original column
graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
expect(graphPanel).toBeDefined();
expect(graphPanel?.group.viewColumn).toBe(originalGraphColumn);
});
it('should handle the issue reproduction scenario', async () => {
const { uri: readmeUri } = await createFile('# Readme', ['readme.md']);
// Step 1-3: Open readme.md
await vscode.window.showTextDocument(
vscode.Uri.file(readmeUri.toFsPath()),
{
viewColumn: vscode.ViewColumn.One,
}
);
// Step 4: Show graph (should appear beside the editor, not in column 1)
await vscode.commands.executeCommand('foam-vscode.show-graph');
await wait(200);
let graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
expect(graphPanel).toBeDefined();
const originalGraphColumn = graphPanel?.group.viewColumn;
// Graph should be beside (to the right of) column 1
expect(originalGraphColumn).toBeGreaterThan(vscode.ViewColumn.One);
// Step 5: Return focus to readme.md
await vscode.window.showTextDocument(
vscode.Uri.file(readmeUri.toFsPath()),
{
viewColumn: vscode.ViewColumn.One,
preserveFocus: false,
}
);
// Step 6: Open markdown preview (simulated by opening another document in the same group as graph)
// In real scenario, this would be the markdown preview, but for testing we'll verify
// that the graph stays in its column when we try to reveal it
// Step 8: Show graph again - it should NOT move
await vscode.commands.executeCommand('foam-vscode.show-graph');
await wait(200);
graphPanel = vscode.window.tabGroups.all
.flatMap(group => group.tabs)
.find(tab => tab.label === 'Foam Graph');
// Graph should still be in its original column, not replaced readme.md
expect(graphPanel?.group.viewColumn).toBe(originalGraphColumn);
});
});

View File

@@ -3,6 +3,7 @@ import { Foam } from '../../core/model/foam';
import { Logger } from '../../core/utils/log';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import { isSome } from '../../core/utils';
import { getFoamVsCodeConfig } from '../../services/config';
export default async function activate(
context: vscode.ExtensionContext,
@@ -10,21 +11,20 @@ export default async function activate(
) {
let panel: vscode.WebviewPanel | undefined = undefined;
vscode.workspace.onDidChangeConfiguration(event => {
if (event.affectsConfiguration('foam.graph.style')) {
const style = getGraphStyle();
panel.webview.postMessage({
type: 'didUpdateStyle',
payload: style,
});
if (panel) {
if (event.affectsConfiguration('foam.graph.style')) {
const style = getGraphStyle();
panel.webview.postMessage({
type: 'didUpdateStyle',
payload: style,
});
}
}
});
vscode.commands.registerCommand('foam-vscode.show-graph', async () => {
if (panel) {
const columnToShowIn = vscode.window.activeTextEditor
? vscode.window.activeTextEditor.viewColumn
: undefined;
panel.reveal(columnToShowIn);
panel.reveal();
} else {
const foam = await foamPromise;
panel = await createGraphPanel(foam, context);
@@ -51,6 +51,10 @@ export default async function activate(
});
}
});
const shouldOpenGraphOnStartup = getFoamVsCodeConfig('graph.onStartup');
if (shouldOpenGraphOnStartup) {
vscode.commands.executeCommand('foam-vscode.show-graph');
}
}
function updateGraph(panel: vscode.WebviewPanel, foam: Foam) {
@@ -111,11 +115,15 @@ function cutTitle(title: string): string {
return title;
}
async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
async function createGraphPanel(
foam: Foam,
context: vscode.ExtensionContext,
viewColumn?: vscode.ViewColumn
) {
const panel = vscode.window.createWebviewPanel(
'foam-graph',
'Foam Graph',
vscode.ViewColumn.Two,
viewColumn ?? vscode.ViewColumn.Beside,
{
enableScripts: true,
retainContextWhenHidden: true,
@@ -133,6 +141,7 @@ async function createGraphPanel(foam: Foam, context: vscode.ExtensionContext) {
type: 'didUpdateStyle',
payload: styles,
});
updateGraph(panel, foam);
break;
}
@@ -174,14 +183,11 @@ async function getWebviewContent(
const getWebviewUri = (fileName: string) =>
panel.webview.asWebviewUri(vscode.Uri.joinPath(datavizUri, fileName));
const indexHtml =
vscode.env.uiKind === vscode.UIKind.Desktop
? new TextDecoder('utf-8').decode(
await vscode.workspace.fs.readFile(
vscode.Uri.joinPath(datavizUri, 'index.html')
)
)
: await fetch(getWebviewUri('index.html').toString()).then(r => r.text());
const indexHtml = new TextDecoder('utf-8').decode(
await vscode.workspace.fs.readFile(
vscode.Uri.joinPath(datavizUri, 'index.html')
)
);
// Replace the script paths with the appropriate webview URI.
const filled = indexHtml.replace(

View File

@@ -4,3 +4,4 @@ export { default as orphans } from './orphans';
export { default as placeholders } from './placeholders';
export { default as tags } from './tags-explorer';
export { default as notes } from './notes-explorer';
export { default as relatedNotes } from '../../ai/vscode/panels/related-notes';

View File

@@ -1,7 +1,10 @@
import * as vscode from 'vscode';
import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getAttachmentsExtensions } from '../../settings';
import {
getAttachmentsExtensions,
getIncludeFilesSetting,
} from '../../settings';
import {
GroupedResourcesConfig,
GroupedResourcesTreeDataProvider,
@@ -21,6 +24,7 @@ export default async function activate(
const foam = await foamPromise;
const { matcher } = await createMatcherAndDataStore(
getIncludeFilesSetting().map(g => g.toString()),
getOrphansConfig().exclude
);
const provider = new OrphanTreeView(

View File

@@ -17,6 +17,7 @@ import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
import { FoamWorkspace } from '../../core/model/workspace';
import { FolderTreeItem } from './utils/folder-tree-provider';
import { getIncludeFilesSetting } from '../../settings';
/** Retrieve the placeholders configuration */
export function getPlaceholdersConfig(): GroupedResourcesConfig {
@@ -31,6 +32,7 @@ export default async function activate(
) {
const foam = await foamPromise;
const { matcher } = await createMatcherAndDataStore(
getIncludeFilesSetting().map(g => g.toString()),
getPlaceholdersConfig().exclude
);
const provider = new PlaceholderTreeView(

View File

@@ -0,0 +1,217 @@
/* @unit-ready */
import MarkdownIt from 'markdown-it';
import {
default as escapeWikilinkPipes,
PIPE_PLACEHOLDER,
} from './escape-wikilink-pipes';
describe('escape-wikilink-pipes plugin', () => {
it('should render table with wikilink alias correctly', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Column |
| --- |
| [[note|alias]] |`;
const html = md.render(markdown);
// Should have proper table structure
expect(html).toContain('<table>');
expect(html).toContain('<tbody>');
// Should preserve the wikilink with pipe character intact
expect(html).toContain('[[note|alias]]');
// Should NOT split into multiple cells (would see extra <td> tags)
const tdCount = (html.match(/<td>/g) || []).length;
expect(tdCount).toBe(1);
});
it('should render table with multiple wikilink aliases in same row', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 | Col3 |
| --- | --- | --- |
| [[a|A]] | [[b|B]] | [[c|C]] |`;
const html = md.render(markdown);
// All three wikilinks should be preserved
expect(html).toContain('[[a|A]]');
expect(html).toContain('[[b|B]]');
expect(html).toContain('[[c|C]]');
// Should have exactly 3 cells in body row
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
expect(bodyMatch).toBeTruthy();
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(3);
});
it('should render table with wikilink containing section and alias', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note#section|alias text]] |`;
const html = md.render(markdown);
// Wikilink with section and alias should be intact
expect(html).toContain('[[note#section|alias text]]');
// Should be in a single cell
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(1);
});
it('should render table with embed wikilink alias correctly', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Embed |
| --- |
| ![[image|caption]] |`;
const html = md.render(markdown);
// Embed wikilink should be preserved
expect(html).toContain('![[image|caption]]');
});
it('should not affect wikilinks without aliases', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note-without-alias]] |`;
const html = md.render(markdown);
// Regular wikilink should still work
expect(html).toContain('[[note-without-alias]]');
});
it('should not affect wikilinks outside of tables', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `
Paragraph with [[note|alias]] link.
| Column |
| --- |
| [[table-note|table-alias]] |
Another [[note2|alias2]] paragraph.
`;
const html = md.render(markdown);
// All wikilinks should be preserved
expect(html).toContain('[[note|alias]]');
expect(html).toContain('[[table-note|table-alias]]');
expect(html).toContain('[[note2|alias2]]');
});
it('should handle table with mixed content and wikilinks', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Text | Link | Mixed |
| --- | --- | --- |
| plain text | [[note|alias]] | text [[link|L]] more |`;
const html = md.render(markdown);
// Both wikilinks should be preserved
expect(html).toContain('[[note|alias]]');
expect(html).toContain('[[link|L]]');
// Should have 3 cells
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyCells = (bodyMatch[0].match(/<td>/g) || []).length;
expect(bodyCells).toBe(3);
});
it('should handle tables without wikilinks', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 |
| --- | --- |
| text | more |`;
const html = md.render(markdown);
// Should render normal table
expect(html).toContain('<table>');
expect(html).toContain('text');
expect(html).toContain('more');
});
it('should not leave placeholder character in rendered output', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Col1 | Col2 |
| --- | --- |
| [[a|A]] | [[b|B]] |`;
const html = md.render(markdown);
// Should not contain the internal placeholder
expect(html).not.toContain(PIPE_PLACEHOLDER);
});
it('should handle complex wikilink aliases with special characters', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Link |
| --- |
| [[note-with-dashes|Alias with spaces & special!]] |`;
const html = md.render(markdown);
expect(html).toContain(
'[[note-with-dashes|Alias with spaces &amp; special!]]'
);
});
it('should handle multiple rows with wikilink aliases', () => {
const md = MarkdownIt();
escapeWikilinkPipes(md);
const markdown = `| Links |
| --- |
| [[note1|alias1]] |
| [[note2|alias2]] |
| [[note3|alias3]] |`;
const html = md.render(markdown);
// All three should be preserved
expect(html).toContain('[[note1|alias1]]');
expect(html).toContain('[[note2|alias2]]');
expect(html).toContain('[[note3|alias3]]');
// Should have 3 rows in tbody
const bodyMatch = html.match(/<tbody>(.*?)<\/tbody>/s);
const bodyRows = (bodyMatch[0].match(/<tr>/g) || []).length;
expect(bodyRows).toBe(3);
});
it('should work when markdown-it does not have table support', () => {
const md = MarkdownIt();
md.disable(['table']);
// Should not throw when table rule doesn't exist
expect(() => escapeWikilinkPipes(md)).not.toThrow();
});
});

View File

@@ -0,0 +1,105 @@
/*global markdownit:readonly*/
/**
* Markdown-it plugin to handle wikilink aliases in tables by wrapping the table parser.
*
* This plugin addresses issue #1544 where wikilink aliases (e.g., [[note|alias]])
* are incorrectly split into separate table cells because the pipe character `|`
* is used both as a wikilink alias separator and a table column separator.
*
* The plugin works by wrapping the table block parser:
* 1. Before the table parser runs, temporarily replace pipes in wikilinks with a placeholder
* 2. Let the table parser create the table structure and inline tokens
* 3. After the table parser returns, restore pipes in the inline token content
* 4. Later inline parsing will see the correct wikilink syntax with pipes
*
* This approach keeps all encoding/decoding logic localized to this single function,
* making it invisible to the rest of the codebase.
*/
// Unique placeholder that's unlikely to appear in normal markdown text
// Note: We've tested various text-based placeholders but all fail:
// - "___FOAM_ALIAS_DIVIDER___" - underscores interpreted as emphasis markers
// - "FOAM__INTERNAL__..." - double underscores cause strong emphasis issues
// - "FOAMINTERNALALIASDIVIDERPLACEHOLDER" - gets truncated (output: "[[noteFOAMINTERN")
// Solution: Use a single Unicode character (U+F8FF Private Use Area) that:
// - Has no markdown meaning
// - Won't be split or modified by parsers
// - Is extremely unlikely to appear in user content
export const PIPE_PLACEHOLDER = '\uF8FF';
/**
* Regex to match wikilinks with pipes (aliases or multiple pipes)
* Matches:
* - [[note|alias]]
* - ![[note|alias]] (embeds)
* - [[note#section|alias]]
*/
const WIKILINK_WITH_PIPE_REGEX = /!?\[\[([^\]]*?\|[^\]]*?)\]\]/g;
/**
* Replace pipes within wikilinks with placeholder
*/
function encodePipesInWikilinks(text: string): string {
return text.replace(WIKILINK_WITH_PIPE_REGEX, match => {
return match.replace(/\|/g, PIPE_PLACEHOLDER);
});
}
/**
* Restore pipes from placeholder in text
*/
function decodePipesInWikilinks(text: string): string {
return text.replace(new RegExp(PIPE_PLACEHOLDER, 'g'), '|');
}
export const escapeWikilinkPipes = (md: markdownit) => {
// Get the original table parser function
// Note: __find__ and __rules__ are internal APIs but necessary for wrapping
const ruler = md.block.ruler as any;
const tableRuleIndex = ruler.__find__('table');
if (tableRuleIndex === -1) {
// Table rule not found (maybe GFM tables not enabled), skip wrapping
return md;
}
const originalTableRule = ruler.__rules__[tableRuleIndex].fn;
// Create wrapped table parser
const wrappedTableRule = function (state, startLine, endLine, silent) {
// Store the token count before parsing to identify new tokens
const tokensBefore = state.tokens.length;
// 1. ENCODE: Replace pipes in wikilinks with placeholder in source
const originalSrc = state.src;
state.src = encodePipesInWikilinks(state.src);
// 2. Call the original table parser
// It will create tokens with encoded content (pipes replaced)
const result = originalTableRule(state, startLine, endLine, silent);
// 3. DECODE: Restore pipes in the newly created inline tokens
if (result) {
// Only process tokens that were created by this table parse
for (let i = tokensBefore; i < state.tokens.length; i++) {
const token = state.tokens[i];
// Inline tokens contain the cell content that needs decoding
if (token.type === 'inline' && token.content) {
token.content = decodePipesInWikilinks(token.content);
}
}
}
// 4. Restore original source
state.src = originalSrc;
return result;
};
// Replace the table rule with our wrapped version
md.block.ruler.at('table', wrappedTableRule);
return md;
};
export default escapeWikilinkPipes;

View File

@@ -6,6 +6,7 @@ import { default as markdownItFoamTags } from './tag-highlight';
import { default as markdownItWikilinkNavigation } from './wikilink-navigation';
import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';
import { default as markdownItWikilinkEmbed } from './wikilink-embed';
import { default as escapeWikilinkPipes } from './escape-wikilink-pipes';
export default async function activate(
context: vscode.ExtensionContext,
@@ -16,6 +17,7 @@ export default async function activate(
return {
extendMarkdownIt: (md: markdownit) => {
return [
escapeWikilinkPipes,
markdownItWikilinkEmbed,
markdownItFoamTags,
markdownItWikilinkNavigation,

View File

@@ -3,8 +3,6 @@
import markdownItRegex from 'markdown-it-regex';
import { FoamWorkspace } from '../../core/model/workspace';
import { Logger } from '../../core/utils/log';
import { isNone } from '../../core/utils';
import { commandAsURI } from '../../utils/commands';
export const markdownItFoamTags = (
md: markdownit,

View File

@@ -5,6 +5,7 @@ import { createTestNote } from '../../test/test-utils';
import { getUriInWorkspace } from '../../test/test-utils-vscode';
import { default as markdownItWikilinkNavigation } from './wikilink-navigation';
import { default as markdownItRemoveLinkReferences } from './remove-wikilink-references';
import { default as escapeWikilinkPipes } from './escape-wikilink-pipes';
describe('Link generation in preview', () => {
const noteA = createTestNote({
@@ -23,6 +24,7 @@ describe('Link generation in preview', () => {
const ws = new FoamWorkspace().set(noteA).set(noteB);
const md = [
escapeWikilinkPipes,
markdownItWikilinkNavigation,
markdownItRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, ws), MarkdownIt());
@@ -91,4 +93,71 @@ describe('Link generation in preview', () => {
`<p><a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">this note</a></p>\n`
);
});
describe('wikilinks with aliases in tables', () => {
it('generates a link with alias inside a table cell', () => {
const table = `| Week | Week again |
| --- | --- |
| [[note-a|W44]] | [[note-b|W45]] |`;
const result = md.render(table);
// Should contain proper links with aliases
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>W44</a>`
);
expect(result).toContain(
`<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>W45</a>`
);
});
it('generates a link with alias and section inside a table cell', () => {
const table = `| Week |
| --- |
| [[note-b#sec1|Week 1]] |`;
const result = md.render(table);
expect(result).toContain(
`<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>`
);
});
it('generates placeholder link with alias inside a table cell', () => {
const table = `| Week |
| --- |
| [[nonexistent|Placeholder]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">Placeholder</a>`
);
});
it('handles multiple wikilinks with aliases in the same table row', () => {
const table = `| Col1 | Col2 | Col3 |
| --- | --- | --- |
| [[note-a|A]] | [[note-b|B]] | [[placeholder|P]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>A</a>`
);
expect(result).toContain(
`<a class='foam-note-link' title='${noteB.title}' href='/path2/to/note-b.md' data-href='/path2/to/note-b.md'>B</a>`
);
expect(result).toContain(
`<a class='foam-placeholder-link' title="Link to non-existing resource" href="javascript:void(0);">P</a>`
);
});
it('handles wikilinks without aliases in tables (should still work)', () => {
const table = `| Week |
| --- |
| [[note-a]] |`;
const result = md.render(table);
expect(result).toContain(
`<a class='foam-note-link' title='${noteA.title}' href='/path/to/note-a.md' data-href='/path/to/note-a.md'>${noteA.title}</a>`
);
});
});
});

View File

@@ -49,8 +49,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
});
describe('provideWorkspaceSymbols', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
it('should return empty array when workspace is empty', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
@@ -76,7 +74,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
workspace.set(resource);
@@ -107,7 +104,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
workspace.set(resource);
@@ -138,7 +134,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
const resource2: Resource = {
@@ -155,7 +150,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
workspace.set(resource1);
@@ -192,7 +186,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
workspace.set(resource);
@@ -221,7 +214,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
},
],
links: [],
definitions: [],
};
workspace.set(resource);

View File

@@ -225,14 +225,38 @@ export function asAbsoluteWorkspaceUri(
return res;
}
export async function createMatcherAndDataStore(excludes: string[]): Promise<{
export async function createMatcherAndDataStore(
includes: string[],
excludes: string[]
): Promise<{
matcher: IMatcher;
dataStore: IDataStore;
includePatterns: Map<string, string[]>;
excludePatterns: Map<string, string[]>;
}> {
const includePatterns = new Map<string, string[]>();
const excludePatterns = new Map<string, string[]>();
workspace.workspaceFolders.forEach(f => excludePatterns.set(f.name, []));
workspace.workspaceFolders.forEach(f => {
includePatterns.set(f.name, []);
excludePatterns.set(f.name, []);
});
// Process include patterns
for (const include of includes) {
const tokens = include.split('/');
const matchesFolder = workspace.workspaceFolders.find(
f => f.name === tokens[0]
);
if (matchesFolder) {
includePatterns.get(tokens[0]).push(tokens.slice(1).join('/'));
} else {
for (const [, value] of includePatterns.entries()) {
value.push(include);
}
}
}
// Process exclude patterns
for (const exclude of excludes) {
const tokens = exclude.split('/');
const matchesFolder = workspace.workspaceFolders.find(
@@ -248,19 +272,41 @@ export async function createMatcherAndDataStore(excludes: string[]): Promise<{
}
const listFiles = async () => {
let files: Uri[] = [];
let allFiles: Uri[] = [];
for (const folder of workspace.workspaceFolders) {
const uris = await workspace.findFiles(
new RelativePattern(folder.uri, '**/*'),
new RelativePattern(
folder.uri,
`{${excludePatterns.get(folder.name).join(',')}}`
)
const folderIncludes = includePatterns.get(folder.name);
const folderExcludes = excludePatterns.get(folder.name);
const excludePattern =
folderExcludes.length > 0
? new RelativePattern(folder.uri, `{${folderExcludes.join(',')}}`)
: null;
// If includes are empty, include nothing
if (folderIncludes.length === 0) {
continue;
}
const filesFromAllPatterns: Uri[] = [];
// Apply each include pattern
for (const includePattern of folderIncludes) {
const uris = await workspace.findFiles(
new RelativePattern(folder.uri, includePattern),
excludePattern
);
filesFromAllPatterns.push(...uris);
}
// Deduplicate files (same file may match multiple patterns)
const uniqueFiles = Array.from(
new Map(filesFromAllPatterns.map(uri => [uri.fsPath, uri])).values()
);
files = [...files, ...uris];
allFiles = [...allFiles, ...uniqueFiles];
}
return files.map(fromVsCodeUri);
return allFiles.map(fromVsCodeUri);
};
const decoder = new TextDecoder('utf-8');
@@ -270,9 +316,14 @@ export async function createMatcherAndDataStore(excludes: string[]): Promise<{
};
const dataStore = new GenericDataStore(listFiles, readFile);
const matcher = isEmpty(excludes)
? new AlwaysIncludeMatcher()
: await FileListBasedMatcher.createFromListFn(listFiles);
const matcher =
isEmpty(excludes) && includes.length === 1 && includes[0] === '**/*'
? new AlwaysIncludeMatcher()
: await FileListBasedMatcher.createFromListFn(
listFiles,
includes,
excludes
);
return { matcher, dataStore, excludePatterns };
return { matcher, dataStore, includePatterns, excludePatterns };
}

View File

@@ -474,4 +474,205 @@ Content without filepath metadata.`,
});
});
});
describe('filepath sanitization', () => {
it('should sanitize invalid characters in filepath from template', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: \${FOAM_TITLE}.md
---
# \${FOAM_TITLE}`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
// Title with many invalid characters
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'Test#%&{}<>?*$!\'"Title@+`|=');
const result = await engine.processTemplate(trigger, template, resolver);
// All invalid characters should become dashes, and valid should stay unchanged
expect(result.filepath.path).toBe("Test#%&{}----$!'-Title@+`-=.md");
// Content should remain unchanged
expect(result.content).toContain('# Test#%&{}<>?*$!\'"Title@+`|=');
});
it('should not affect FOAM_TITLE when not used in filepath', async () => {
const { engine } = await setupFoamEngine();
// Template with static filepath, FOAM_TITLE only in content
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: notes/static-file.md
---
# \${FOAM_TITLE}
Content with \${FOAM_TITLE} should remain unchanged.`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'Invalid "Characters" <Test>');
const result = await engine.processTemplate(trigger, template, resolver);
// Filepath should remain static (no sanitization needed)
expect(result.filepath.path).toBe('notes/static-file.md');
// Content should use original FOAM_TITLE with invalid characters
expect(result.content).toContain('# Invalid "Characters" <Test>');
expect(result.content).toContain(
'Content with Invalid "Characters" <Test> should remain'
);
});
it('should sanitize complex filepath patterns with multiple variables', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: \${FOAM_DATE_YEAR}/\${FOAM_DATE_MONTH}/\${FOAM_TITLE}.md
---
# \${FOAM_TITLE}
Date and title combination.`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const testDate = new Date('2024-03-15');
const resolver = new Resolver(new Map(), testDate);
resolver.define('FOAM_TITLE', 'Note:With|Invalid*Chars');
resolver.define('FOAM_DATE_YEAR', '2024');
resolver.define('FOAM_DATE_MONTH', '03');
const result = await engine.processTemplate(trigger, template, resolver);
// Entire resolved filepath should be sanitized
expect(result.filepath.path).toBe('2024/03/Note:With-Invalid-Chars.md');
// Content should use original FOAM_TITLE
expect(result.content).toContain('# Note:With|Invalid*Chars');
});
it('should handle filepath with no invalid characters', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: notes/\${FOAM_TITLE}.md
---
# \${FOAM_TITLE}`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'ValidTitle123');
const result = await engine.processTemplate(trigger, template, resolver);
// No sanitization needed - should remain unchanged
expect(result.filepath.path).toBe('notes/ValidTitle123.md');
expect(result.content).toContain('# ValidTitle123');
});
it('should preserve backslashes as directory separators (Windows-style paths)', async () => {
const { engine } = await setupFoamEngine();
// Simulate a resolved filepath with Windows-style backslash separators
const template: Template = {
type: 'markdown',
content: `# MyNote`,
metadata: new Map([
['filepath', 'areas\\dailies\\2024\\MyNote.md'], // Already resolved, has backslashes
]),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
const result = await engine.processTemplate(trigger, template, resolver);
// Backslashes should be normalized to forward slashes
expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md');
expect(result.content).toContain('# MyNote');
});
it('should normalize mixed forward and backslashes', async () => {
const { engine } = await setupFoamEngine();
// Simulate a resolved filepath with mixed separators
const template: Template = {
type: 'markdown',
content: `# MyNote`,
metadata: new Map([
['filepath', 'areas/dailies\\2024/MyNote.md'], // Mixed separators
]),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
const result = await engine.processTemplate(trigger, template, resolver);
// Both separators should be normalized to forward slashes
expect(result.filepath.path).toBe('areas/dailies/2024/MyNote.md');
expect(result.content).toContain('# MyNote');
});
it('should sanitize invalid characters while normalizing backslash separators', async () => {
const { engine } = await setupFoamEngine();
// Simulate a resolved filepath with backslash separator and invalid chars
const template: Template = {
type: 'markdown',
content: `# Note:With*Invalid`,
metadata: new Map([['filepath', 'areas\\Note:With*Invalid.md']]), // Backslash + invalid chars
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
const result = await engine.processTemplate(trigger, template, resolver);
// Backslash normalized to forward slash, invalid chars sanitized
expect(result.filepath.path).toBe('areas/Note:With-Invalid.md');
expect(result.content).toContain('# Note:With*Invalid');
});
});
});

View File

@@ -12,6 +12,25 @@ import {
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
import { URI } from '../core/model/uri';
/**
* Characters that are invalid in file names
* Based on UNALLOWED_CHARS from variable-resolver.ts but excluding filepaths
* related chars and chars permissible in filepaths
*/
const FILEPATH_UNALLOWED_CHARS = '<>?*"|';
/**
* Sanitizes a filepath by replacing invalid characters with dashes
* @param filepath The filepath to sanitize
* @returns The sanitized filepath
*/
function sanitizeFilepath(filepath: string): string {
// Escape special regex characters and create character class
const escapedChars = FILEPATH_UNALLOWED_CHARS.replace(/[\\^\-\]]/g, '\\$&');
const regex = new RegExp(`[${escapedChars}]`, 'g');
return filepath.replace(regex, '-');
}
/**
* Unified engine for creating notes from both Markdown and JavaScript templates
*/
@@ -109,10 +128,13 @@ export class NoteCreationEngine {
]);
// Determine filepath - get variables from resolver for default generation
const filepath =
let filepath =
metadata.get('filepath') ??
(await this.generateDefaultFilepath(resolver));
// Sanitize the filepath to remove invalid characters
filepath = sanitizeFilepath(filepath);
return {
filepath: this.roots[0].forPath(filepath),
content: cleanContent,

View File

@@ -156,6 +156,13 @@ describe('variable-resolver, variable resolution', () => {
expect(await resolver.resolveAll(variables)).toEqual(expected);
});
function getISOWeekYear(date: Date): number {
const temp = new Date(date.getTime());
// Set to Thursday of this week (ISO 8601 defines week based on Thursday)
temp.setDate(temp.getDate() + 3 - ((temp.getDay() + 6) % 7));
return temp.getFullYear();
}
it('should resolve FOAM_DATE_* properties with current day by default', async () => {
const variables = [
new Variable('FOAM_DATE_YEAR'),
@@ -171,6 +178,7 @@ describe('variable-resolver, variable resolution', () => {
new Variable('FOAM_DATE_SECOND'),
new Variable('FOAM_DATE_SECONDS_UNIX'),
new Variable('FOAM_DATE_DAY_ISO'),
new Variable('FOAM_DATE_WEEK_YEAR'),
];
const expected = new Map<string, string>();
@@ -188,6 +196,8 @@ describe('variable-resolver, variable resolution', () => {
now.toLocaleString('default', { day: '2-digit' })
);
expected.set('FOAM_DATE_DAY_ISO', String(((now.getDay() + 6) % 7) + 1));
expected.set('FOAM_DATE_WEEK_YEAR', String(getISOWeekYear(now)));
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, new Date());
@@ -213,6 +223,7 @@ describe('variable-resolver, variable resolution', () => {
new Variable('FOAM_DATE_SECONDS_UNIX'),
new Variable('FOAM_DATE_WEEK'),
new Variable('FOAM_DATE_DAY_ISO'),
new Variable('FOAM_DATE_WEEK_YEAR'),
];
const expected = new Map<string, string>();
@@ -233,6 +244,7 @@ describe('variable-resolver, variable resolution', () => {
(targetDate.getTime() / 1000).toString()
);
expected.set('FOAM_DATE_DAY_ISO', '5'); // Friday is 5 in ISO 8601
expected.set('FOAM_DATE_WEEK_YEAR', '2021');
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, targetDate);
@@ -265,6 +277,44 @@ describe('variable-resolver, variable resolution', () => {
});
});
test.each([
[new Date(2025, 0, 1), '2025', '01'], // Jan 1 2025 (Wed) - week 1 of 2025
[new Date(2024, 11, 30), '2025', '01'], // Dec 30 2024 (Mon) - week 1 of 2025
[new Date(2024, 0, 1), '2024', '01'], // Jan 1 2024 (Mon) - week 1 of 2024
[new Date(2023, 0, 1), '2022', '52'], // Jan 1 2023 (Sun) - week 52 of 2022
[new Date(2022, 0, 1), '2021', '52'], // Jan 1 2022 (Sat) - week 52 of 2021
])(
'should resolve FOAM_DATE_WEEK_YEAR correctly',
async (date, expectedWeekYear, expectedWeek) => {
const resolver = new Resolver(new Map(), date);
const weekYear = await resolver.resolve(
new Variable('FOAM_DATE_WEEK_YEAR')
);
const week = await resolver.resolve(new Variable('FOAM_DATE_WEEK'));
expect(weekYear).toBe(expectedWeekYear);
expect(week).toBe(expectedWeek);
}
);
it('should resolve FOAM_DATE_WEEK_YEAR with FOAM_DATE_WEEK in template', async () => {
// Example: 2024-W01 format where Dec 30, 2024 is in week 1 of 2025
const date = new Date(2024, 11, 30); // Dec 30, 2024 (Monday)
const resolver = new Resolver(new Map(), date);
const variables = [
new Variable('FOAM_DATE_WEEK_YEAR'),
new Variable('FOAM_DATE_WEEK'),
new Variable('FOAM_DATE_YEAR'),
];
const result = await resolver.resolveAll(variables);
expect(result.get('FOAM_DATE_WEEK_YEAR')).toBe('2025');
expect(result.get('FOAM_DATE_WEEK')).toBe('01');
expect(result.get('FOAM_DATE_YEAR')).toBe('2024');
});
describe('FOAM_CURRENT_DIR', () => {
it('should resolve to workspace root when no active editor', async () => {
const resolver = new Resolver(new Map<string, string>(), new Date());

Some files were not shown because too many files have changed in this diff Show More