mirror of
https://github.com/foambubble/foam.git
synced 2026-01-11 15:08:01 -05:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80b1537324 | ||
|
|
007315c3a1 | ||
|
|
ec1750d5a6 | ||
|
|
5480c65a48 | ||
|
|
7afa286ea5 | ||
|
|
4a7c2d9de2 | ||
|
|
1f6b2abce2 | ||
|
|
5f017ee4ea | ||
|
|
61032668be | ||
|
|
9192cefc7c | ||
|
|
2f966276b5 | ||
|
|
145970a6cb | ||
|
|
54a6ffdf01 | ||
|
|
40740db416 | ||
|
|
145653ec85 | ||
|
|
503b486179 | ||
|
|
a36d39acf8 | ||
|
|
fb92790a0a | ||
|
|
dcb951004a |
2
.github/workflows/update-docs.yml
vendored
2
.github/workflows/update-docs.yml
vendored
@@ -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
|
||||
|
||||
2
.vscode/settings.json
vendored
2
.vscode/settings.json
vendored
@@ -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]": {
|
||||
|
||||
@@ -25,7 +25,7 @@ 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
|
||||
@@ -37,11 +37,10 @@ When running tests, do not provide additional parameters, they are ignored by th
|
||||
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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -296,10 +296,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'
|
||||
|
||||
@@ -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?)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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]]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.28.1"
|
||||
"version": "0.29.0"
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,28 @@ 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.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:
|
||||
|
||||
@@ -25,6 +25,9 @@ const config = {
|
||||
platform: 'browser',
|
||||
format: 'cjs',
|
||||
outfile: `out/bundles/extension-web.js`,
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
plugins: [
|
||||
polyfillPlugin.polyfillNode({
|
||||
// Options (optional)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git"
|
||||
},
|
||||
"homepage": "https://github.com/foambubble/foam",
|
||||
"version": "0.28.1",
|
||||
"version": "0.29.0",
|
||||
"license": "MIT",
|
||||
"publisher": "foam",
|
||||
"engines": {
|
||||
@@ -367,12 +367,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",
|
||||
@@ -526,6 +526,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"
|
||||
@@ -711,8 +724,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",
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,134 +1,188 @@
|
||||
import { generateLinkReferences } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../services/markdown-provider';
|
||||
import { Resource } from '../model/note';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
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';
|
||||
|
||||
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 parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# Index
|
||||
|
||||
This file is intentionally missing the link reference definitions
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[[file-without-title]]
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
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"`
|
||||
[file-without-title]: file-without-title "file-without-title"`
|
||||
),
|
||||
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,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual![0].range.start).toEqual(expected.range.start);
|
||||
expect(actual![0].range.end).toEqual(expected.range.end);
|
||||
expect(actual![0].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 parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
);
|
||||
const noteText = `# Second Document
|
||||
|
||||
This is just a link target for now.
|
||||
|
||||
We can use it for other things later if needed.
|
||||
|
||||
[first-document]: first-document 'First Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = {
|
||||
newText: '',
|
||||
range: Range.create(6, 0, 8, 42),
|
||||
range: Range.create(6, 0, 6, 49),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(1);
|
||||
expect(actual[0]!.range.start).toEqual(expected.range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected.range.end);
|
||||
expect(actual[0]!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should update link definitions if they are present but changed', async () => {
|
||||
const note = findBySlug('first-document');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# 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),
|
||||
};
|
||||
Here's some [unrelated] content.
|
||||
|
||||
[unrelated]: http://unrelated.com 'This link should not be changed'
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[second-document]: second-document 'Second Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [
|
||||
{
|
||||
newText: '',
|
||||
range: Range.create(8, 0, 8, 52),
|
||||
},
|
||||
{
|
||||
newText: textForNote(
|
||||
`\n[file-without-title]: file-without-title "file-without-title"`
|
||||
),
|
||||
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,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(2);
|
||||
expect(actual[0]!.range.start).toEqual(expected[0].range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected[0].range.end);
|
||||
expect(actual[0]!.newText).toEqual(expected[0].newText);
|
||||
expect(actual[1]!.range.start).toEqual(expected[1].range.start);
|
||||
expect(actual[1]!.range.end).toEqual(expected[1].range.end);
|
||||
expect(actual[1]!.newText).toEqual(expected[1].newText);
|
||||
});
|
||||
|
||||
it('should not cause any changes if link reference definitions were up to date', async () => {
|
||||
const note = findBySlug('third-document');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')])
|
||||
.set(
|
||||
createTestNote({ uri: '/first-document.md', title: 'First Document' })
|
||||
)
|
||||
.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
const noteText = `# Third Document
|
||||
|
||||
const expected = null;
|
||||
All the link references are correct in this file.
|
||||
|
||||
[[first-document]]
|
||||
|
||||
[[second-document]]
|
||||
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [];
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -136,43 +190,71 @@ describe('generateLinkReferences', () => {
|
||||
});
|
||||
|
||||
it('should put links with spaces in angel brackets', async () => {
|
||||
const note = findBySlug('angel-reference');
|
||||
const parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]).set(
|
||||
createTestNote({
|
||||
uri: '/Note being referred as angel.md',
|
||||
title: 'Note being referred as angel',
|
||||
})
|
||||
);
|
||||
const noteText = `# Angel reference
|
||||
|
||||
[[Note being referred as angel]]
|
||||
`;
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
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"`
|
||||
[Note being referred as angel]: <Note being referred as angel> "Note being referred as angel"`
|
||||
),
|
||||
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,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
expect(actual.length).toBe(1);
|
||||
expect(actual[0]!.range.start).toEqual(expected.range.start);
|
||||
expect(actual[0]!.range.end).toEqual(expected.range.end);
|
||||
expect(actual[0]!.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 parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
workspace.set(
|
||||
createTestNote({
|
||||
uri: '/file-without-title.md',
|
||||
title: 'file-without-title',
|
||||
})
|
||||
);
|
||||
const noteText = `# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference]
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const expected = [];
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
@@ -180,27 +262,33 @@ describe('generateLinkReferences', () => {
|
||||
});
|
||||
|
||||
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 parser = createMarkdownParser();
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
workspace.set(
|
||||
createTestNote({ uri: '/second-document.md', title: 'Second Document' })
|
||||
);
|
||||
const noteText = `# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference].
|
||||
I also want a [[first-document]].
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
[first-document]: first-document 'First Document'
|
||||
`;
|
||||
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
expect(actual.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1,80 +1,74 @@
|
||||
import { NoteLinkDefinition, Resource } from '../model/note';
|
||||
import { NoteLinkDefinition, Resource, ResourceLink } from '../model/note';
|
||||
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 nLines = currentNoteText.split(eol).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) {
|
||||
const lastLine = currentNoteText.split(eol)[nLines - 1];
|
||||
const isLastLineEmpty = lastLine.trim().length === 0;
|
||||
|
||||
// check if the new references match the existing references
|
||||
const existingReferences = lines
|
||||
.slice(targetRange.start.line, targetRange.end.line + 1)
|
||||
.join(eol);
|
||||
let text = isLastLineEmpty ? '' : eol;
|
||||
for (const def of toAddWikilinkDefinitions) {
|
||||
// Choose the correct position for insertion, e.g., end of file or after last reference
|
||||
text = `${text}${eol}${NoteLinkDefinition.format(def)}`;
|
||||
}
|
||||
edits.push({
|
||||
range: Range.create(
|
||||
nLines - 1,
|
||||
lastLine.length,
|
||||
nLines - 1,
|
||||
lastLine.length
|
||||
),
|
||||
newText: text,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
return existingReferences === newReferences
|
||||
? null
|
||||
: {
|
||||
newText: `${padding}${newReferences}`,
|
||||
range: targetRange,
|
||||
};
|
||||
return edits;
|
||||
};
|
||||
|
||||
@@ -264,15 +264,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' })
|
||||
|
||||
@@ -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 {
|
||||
@@ -52,9 +87,6 @@ export interface Resource {
|
||||
tags: Tag[];
|
||||
aliases: Alias[];
|
||||
links: ResourceLink[];
|
||||
|
||||
// TODO to remove
|
||||
definitions: NoteLinkDefinition[];
|
||||
}
|
||||
|
||||
export interface ResourceParser {
|
||||
|
||||
@@ -26,7 +26,6 @@ const asResource = (uri: URI): Resource => {
|
||||
sections: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
definitions: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ?? '',
|
||||
|
||||
@@ -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,74 @@ this is some text with our [[second-wikilink]].
|
||||
'[[second-wikilink]]',
|
||||
]);
|
||||
});
|
||||
|
||||
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"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('link');
|
||||
expect(link.rawText).toEqual('[reference-style link][missing-ref]');
|
||||
expect(ResourceLink.isUnresolvedReference(link)).toBe(true);
|
||||
expect(link.definition).toEqual('missing-ref');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -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,21 @@ 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;
|
||||
}
|
||||
});
|
||||
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
@@ -359,6 +404,7 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
rawText: literalContent,
|
||||
range,
|
||||
isEmbed,
|
||||
definition: (node as any).value,
|
||||
});
|
||||
}
|
||||
if (node.type === 'link' || node.type === 'image') {
|
||||
@@ -378,24 +424,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 +463,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)
|
||||
|
||||
@@ -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,8 @@ 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([]);
|
||||
expect(noteD.links.length).toEqual(1);
|
||||
expect(noteD.links[0].definition).toEqual('note'); // Unresolved reference
|
||||
});
|
||||
|
||||
describe('Workspace-relative paths (root-path relative)', () => {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal file
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal file
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal file
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal 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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,6 @@ 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';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
202
packages/foam-vscode/src/features/commands/search-tag.spec.ts
Normal file
202
packages/foam-vscode/src/features/commands/search-tag.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
149
packages/foam-vscode/src/features/panels/dataviz.spec.ts
Normal file
149
packages/foam-vscode/src/features/panels/dataviz.spec.ts
Normal 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.Two 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.Two);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -21,13 +21,13 @@ export default async function activate(
|
||||
|
||||
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 columnToShowIn = vscode.window.activeTextEditor
|
||||
? vscode.ViewColumn.Beside
|
||||
: vscode.ViewColumn.Two;
|
||||
const foam = await foamPromise;
|
||||
panel = await createGraphPanel(foam, context);
|
||||
panel = await createGraphPanel(foam, context, columnToShowIn);
|
||||
const onFoamChanged = _ => {
|
||||
updateGraph(panel, foam);
|
||||
};
|
||||
@@ -111,11 +111,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.Two,
|
||||
{
|
||||
enableScripts: true,
|
||||
retainContextWhenHidden: true,
|
||||
|
||||
@@ -76,7 +76,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -107,7 +106,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -138,7 +136,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
const resource2: Resource = {
|
||||
@@ -155,7 +152,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
workspace.set(resource1);
|
||||
@@ -192,7 +188,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
@@ -221,7 +216,6 @@ describe('FoamWorkspaceSymbolProvider', () => {
|
||||
},
|
||||
],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
workspace.set(resource);
|
||||
|
||||
|
||||
@@ -493,17 +493,17 @@ foam_template:
|
||||
'foam-vscode.create-note'
|
||||
);
|
||||
|
||||
// Title with many invalid characters (excluding / which is preserved for directories): \#%&{}<>?*$!'":@+`|=
|
||||
// Title with many invalid characters
|
||||
const resolver = new Resolver(new Map(), new Date());
|
||||
resolver.define('FOAM_TITLE', 'Test\\#%&{}<>?*$!\'"Title:@+`|=');
|
||||
resolver.define('FOAM_TITLE', 'Test#%&{}<>?*$!\'"Title@+`|=');
|
||||
|
||||
const result = await engine.processTemplate(trigger, template, resolver);
|
||||
|
||||
// All invalid characters should become dashes: Test + 14 invalid chars + Title + : + @+`|= (6 more total)
|
||||
expect(result.filepath.path).toBe('Test--------------Title------.md');
|
||||
// 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:@+`|=');
|
||||
expect(result.content).toContain('# Test#%&{}<>?*$!\'"Title@+`|=');
|
||||
});
|
||||
|
||||
it('should not affect FOAM_TITLE when not used in filepath', async () => {
|
||||
@@ -569,7 +569,7 @@ Date and title combination.`,
|
||||
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');
|
||||
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');
|
||||
@@ -601,5 +601,78 @@ foam_template:
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,14 +14,13 @@ import { URI } from '../core/model/uri';
|
||||
|
||||
/**
|
||||
* Characters that are invalid in file names
|
||||
* Based on UNALLOWED_CHARS from variable-resolver.ts but excluding forward slash
|
||||
* which is needed for directory separators in filepaths
|
||||
* Based on UNALLOWED_CHARS from variable-resolver.ts but excluding filepaths
|
||||
* related chars and chars permissible in filepaths
|
||||
*/
|
||||
const FILEPATH_UNALLOWED_CHARS = '\\#%&{}<>?*$!\'":@+`|=';
|
||||
const FILEPATH_UNALLOWED_CHARS = '<>?*"|';
|
||||
|
||||
/**
|
||||
* Sanitizes a filepath by replacing invalid characters with dashes
|
||||
* Note: Forward slashes (/) are preserved for directory separators
|
||||
* @param filepath The filepath to sanitize
|
||||
* @returns The sanitized filepath
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -23,6 +23,7 @@ const knownFoamVariables = new Set([
|
||||
'FOAM_DATE_DATE',
|
||||
'FOAM_DATE_DAY_ISO',
|
||||
'FOAM_DATE_WEEK',
|
||||
'FOAM_DATE_WEEK_YEAR',
|
||||
'FOAM_DATE_DAY_NAME',
|
||||
'FOAM_DATE_DAY_NAME_SHORT',
|
||||
'FOAM_DATE_HOUR',
|
||||
@@ -216,6 +217,18 @@ export class Resolver implements VariableResolver {
|
||||
value = Promise.resolve(String(weekDay.valueOf()).padStart(2, '0'));
|
||||
break;
|
||||
}
|
||||
case 'FOAM_DATE_WEEK_YEAR': {
|
||||
// ISO 8601 week-numbering year
|
||||
// The year that contains the Thursday of the current week
|
||||
const date = new Date(this.foamDate);
|
||||
|
||||
// Find Thursday of this week starting on Monday
|
||||
date.setDate(date.getDate() + 4 - (date.getDay() || 7));
|
||||
|
||||
// The year of this Thursday is the ISO week year
|
||||
value = Promise.resolve(String(date.getFullYear()));
|
||||
break;
|
||||
}
|
||||
case 'FOAM_DATE_DAY_NAME':
|
||||
value = Promise.resolve(
|
||||
this.foamDate.toLocaleString('default', { weekday: 'long' })
|
||||
|
||||
@@ -24,6 +24,43 @@ import * as glob from 'glob';
|
||||
|
||||
const rootDir = path.join(__dirname, '..', '..');
|
||||
|
||||
function parseJestArgs(args: string[]): any {
|
||||
const config: any = {};
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
|
||||
if (arg === '--testNamePattern' && i + 1 < args.length) {
|
||||
config.testNamePattern = args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--testPathPattern' && i + 1 < args.length) {
|
||||
config.testPathPattern = args[i + 1].split('/').at(-1) || args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--json') {
|
||||
config.json = true;
|
||||
} else if (arg === '--useStderr') {
|
||||
config.useStderr = true;
|
||||
} else if (arg === '--outputFile' && i + 1 < args.length) {
|
||||
config.outputFile = args[i + 1];
|
||||
i++; // Skip next arg as it's the value
|
||||
} else if (arg === '--no-coverage') {
|
||||
config.collectCoverage = false;
|
||||
} else if (arg === '--watchAll=false') {
|
||||
config.watchAll = false;
|
||||
} else if (arg === '--colors') {
|
||||
config.colors = true;
|
||||
} else if (arg === '--reporters' && i + 1 < args.length) {
|
||||
if (!config.reporters) {
|
||||
config.reporters = [];
|
||||
}
|
||||
config.reporters.push(args[i + 1]);
|
||||
i++; // Skip next arg as it's the value
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function getUnitReadySpecFiles(rootDir: string): string[] {
|
||||
const specFiles = glob.sync('**/*.spec.ts', {
|
||||
cwd: path.join(rootDir, 'src'),
|
||||
@@ -59,35 +96,37 @@ export function runUnit(
|
||||
return new Promise(async (resolve, reject) => {
|
||||
try {
|
||||
const { results } = await runCLI(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
runInBand: true,
|
||||
testRegex: excludeSpecs
|
||||
? ['\\.(test)\\.ts$']
|
||||
: (() => {
|
||||
const unitReadySpecs = getUnitReadySpecFiles(rootDir);
|
||||
Object.assign(
|
||||
{
|
||||
rootDir,
|
||||
roots: ['<rootDir>/src'],
|
||||
runInBand: true,
|
||||
testRegex: excludeSpecs
|
||||
? ['\\.(test)\\.ts$']
|
||||
: (() => {
|
||||
const unitReadySpecs = getUnitReadySpecFiles(rootDir);
|
||||
|
||||
// Create pattern that includes .test files + specific .spec files
|
||||
return [
|
||||
'\\.(test)\\.ts$', // All .test files
|
||||
...unitReadySpecs.map(
|
||||
file =>
|
||||
file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$'
|
||||
),
|
||||
];
|
||||
})(),
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/test/support/jest-setup-after-env.ts',
|
||||
],
|
||||
testTimeout: 20000,
|
||||
verbose: false,
|
||||
silent: false,
|
||||
colors: true,
|
||||
// Pass through any additional args
|
||||
_: extraArgs,
|
||||
} as any,
|
||||
// Create pattern that includes .test files + specific .spec files
|
||||
return [
|
||||
'\\.(test)\\.ts$', // All .test files
|
||||
...unitReadySpecs.map(
|
||||
file =>
|
||||
file.replace(/\//g, '\\/').replace(/\./g, '\\.') + '$'
|
||||
),
|
||||
];
|
||||
})(),
|
||||
setupFiles: ['<rootDir>/src/test/support/jest-setup.ts'],
|
||||
setupFilesAfterEnv: [
|
||||
'<rootDir>/src/test/support/jest-setup-after-env.ts',
|
||||
],
|
||||
testTimeout: 20000,
|
||||
verbose: false,
|
||||
silent: false,
|
||||
colors: true,
|
||||
},
|
||||
// Parse additional Jest arguments into config object
|
||||
parseJestArgs(extraArgs)
|
||||
) as any,
|
||||
[rootDir]
|
||||
);
|
||||
|
||||
|
||||
@@ -48,8 +48,7 @@ export const createTestWorkspace = (workspaceRoots: URI[] = []) => {
|
||||
export const createTestNote = (params: {
|
||||
uri: string;
|
||||
title?: string;
|
||||
definitions?: NoteLinkDefinition[];
|
||||
links?: Array<{ slug: string } | { to: string }>;
|
||||
links?: Array<{ slug: string; definitionUrl?: string } | { to: string }>;
|
||||
tags?: string[];
|
||||
aliases?: string[];
|
||||
text?: string;
|
||||
@@ -63,7 +62,6 @@ export const createTestNote = (params: {
|
||||
type: params.type ?? 'note',
|
||||
properties: {},
|
||||
title: params.title ?? strToUri(params.uri).getBasename(),
|
||||
definitions: params.definitions ?? [],
|
||||
sections: params.sections?.map(label => ({
|
||||
label,
|
||||
range: Range.create(0, 0, 1, 0),
|
||||
@@ -92,6 +90,13 @@ export const createTestNote = (params: {
|
||||
range: range,
|
||||
rawText: `[[${link.slug}]]`,
|
||||
isEmbed: false,
|
||||
definition: link.definitionUrl
|
||||
? {
|
||||
label: link.slug,
|
||||
url: link.definitionUrl,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
}
|
||||
: link.slug,
|
||||
}
|
||||
: {
|
||||
type: 'link',
|
||||
|
||||
41
packages/foam-vscode/src/test/vscode-mock-extensions.test.ts
Normal file
41
packages/foam-vscode/src/test/vscode-mock-extensions.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import * as vscode from './vscode-mock';
|
||||
|
||||
describe('vscode-mock extensions API', () => {
|
||||
it('should provide extensions.getExtension', () => {
|
||||
expect(vscode.extensions).toBeDefined();
|
||||
expect(vscode.extensions.getExtension).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return foam extension', () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext).toBeDefined();
|
||||
expect(ext?.id).toBe('foam.foam-vscode');
|
||||
expect(ext?.isActive).toBe(true);
|
||||
});
|
||||
|
||||
it('should return undefined for unknown extensions', () => {
|
||||
const ext = vscode.extensions.getExtension('unknown.extension');
|
||||
expect(ext).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should provide foam instance through extension exports', async () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext?.exports).toBeDefined();
|
||||
expect(ext?.exports.foam).toBeDefined();
|
||||
|
||||
// foam is a getter that returns a Promise
|
||||
const foam = await ext?.exports.foam;
|
||||
expect(foam).toBeDefined();
|
||||
expect(foam.workspace).toBeDefined();
|
||||
expect(foam.graph).toBeDefined();
|
||||
});
|
||||
|
||||
it('should support activate() method', async () => {
|
||||
const ext = vscode.extensions.getExtension('foam.foam-vscode');
|
||||
expect(ext?.activate).toBeDefined();
|
||||
|
||||
const exports = await ext?.activate();
|
||||
expect(exports).toBeDefined();
|
||||
expect(exports.foam).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { Position } from '../core/model/position';
|
||||
import { Position as FoamPosition } from '../core/model/position';
|
||||
import { Range as FoamRange } from '../core/model/range';
|
||||
import { URI } from '../core/model/uri';
|
||||
import { Logger } from '../core/utils/log';
|
||||
@@ -35,7 +35,117 @@ interface Thenable<T> {
|
||||
|
||||
// ===== Basic VS Code Types =====
|
||||
|
||||
export { Position };
|
||||
export class Position implements FoamPosition {
|
||||
public readonly line: number;
|
||||
public readonly character: number;
|
||||
constructor(line: number, character: number) {
|
||||
this.line = line;
|
||||
this.character = character;
|
||||
}
|
||||
static create(line: number, character: number): Position {
|
||||
return new Position(line, character);
|
||||
}
|
||||
|
||||
// Instance methods
|
||||
compareTo(other: Position): number {
|
||||
if (this.line < other.line) return -1;
|
||||
if (this.line > other.line) return 1;
|
||||
if (this.character < other.character) return -1;
|
||||
if (this.character > other.character) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
isAfter(other: Position): boolean {
|
||||
return this.compareTo(other) > 0;
|
||||
}
|
||||
|
||||
isAfterOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) >= 0;
|
||||
}
|
||||
|
||||
isBefore(other: Position): boolean {
|
||||
return this.compareTo(other) < 0;
|
||||
}
|
||||
|
||||
isBeforeOrEqual(other: Position): boolean {
|
||||
return this.compareTo(other) <= 0;
|
||||
}
|
||||
|
||||
isEqual(other: Position): boolean {
|
||||
return this.compareTo(other) === 0;
|
||||
}
|
||||
|
||||
translate(lineDelta?: number, characterDelta?: number): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
translate(change: { lineDelta?: number; characterDelta?: number }): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
translate(
|
||||
lineDeltaOrChange?:
|
||||
| number
|
||||
| { lineDelta?: number; characterDelta?: number },
|
||||
characterDelta?: number
|
||||
): Position {
|
||||
let lineDelta: number;
|
||||
let charDelta: number;
|
||||
|
||||
if (typeof lineDeltaOrChange === 'object') {
|
||||
lineDelta = lineDeltaOrChange.lineDelta ?? 0;
|
||||
charDelta = lineDeltaOrChange.characterDelta ?? 0;
|
||||
} else {
|
||||
lineDelta = lineDeltaOrChange ?? 0;
|
||||
charDelta = characterDelta ?? 0;
|
||||
}
|
||||
|
||||
return new Position(this.line + lineDelta, this.character + charDelta);
|
||||
}
|
||||
|
||||
with(line?: number, character?: number): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
with(change: { line?: number; character?: number }): Position;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
with(
|
||||
lineOrChange?: number | { line?: number; character?: number },
|
||||
character?: number
|
||||
): Position {
|
||||
let line: number;
|
||||
let char: number;
|
||||
|
||||
if (typeof lineOrChange === 'object') {
|
||||
line = lineOrChange.line ?? this.line;
|
||||
char = lineOrChange.character ?? this.character;
|
||||
} else {
|
||||
line = lineOrChange ?? this.line;
|
||||
char = character ?? this.character;
|
||||
}
|
||||
|
||||
return new Position(line, char);
|
||||
}
|
||||
|
||||
// Static helper methods
|
||||
static isAfter(a: Position, b: Position): boolean {
|
||||
return a.isAfter(b);
|
||||
}
|
||||
|
||||
static isAfterOrEqual(a: Position, b: Position): boolean {
|
||||
return a.isAfterOrEqual(b);
|
||||
}
|
||||
|
||||
static isBefore(a: Position, b: Position): boolean {
|
||||
return a.isBefore(b);
|
||||
}
|
||||
|
||||
static isBeforeOrEqual(a: Position, b: Position): boolean {
|
||||
return a.isBeforeOrEqual(b);
|
||||
}
|
||||
|
||||
static isEqual(a: Position, b: Position): boolean {
|
||||
return a.isEqual(b);
|
||||
}
|
||||
|
||||
static compareTo(a: Position, b: Position): number {
|
||||
return a.compareTo(b);
|
||||
}
|
||||
}
|
||||
|
||||
// VS Code Range class
|
||||
export class Range implements FoamRange {
|
||||
@@ -56,8 +166,8 @@ export class Range implements FoamRange {
|
||||
endCharacter?: number
|
||||
) {
|
||||
if (typeof startOrLine === 'number') {
|
||||
this.start = { line: startOrLine, character: endOrCharacter as number };
|
||||
this.end = { line: endLine!, character: endCharacter! };
|
||||
this.start = new Position(startOrLine, endOrCharacter as number);
|
||||
this.end = new Position(endLine!, endCharacter!);
|
||||
} else {
|
||||
this.start = startOrLine;
|
||||
this.end = endOrCharacter as Position;
|
||||
@@ -237,8 +347,8 @@ export class Selection extends Range {
|
||||
let active: Position;
|
||||
|
||||
if (typeof anchorOrLine === 'number') {
|
||||
anchor = { line: anchorOrLine, character: activeOrCharacter as number };
|
||||
active = { line: activeLine!, character: activeCharacter! };
|
||||
anchor = new Position(anchorOrLine, activeOrCharacter as number);
|
||||
active = new Position(activeLine!, activeCharacter!);
|
||||
} else {
|
||||
anchor = anchorOrLine;
|
||||
active = activeOrCharacter as Position;
|
||||
@@ -819,6 +929,7 @@ class MockTextDocument implements TextDocument {
|
||||
fs.writeFileSync(this.uri.fsPath, content);
|
||||
} catch (error) {
|
||||
Logger.error('vscode-mock: Failed to write file', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -854,6 +965,32 @@ class MockTextEditor implements TextEditor {
|
||||
|
||||
async edit(callback: (editBuilder: any) => void): Promise<boolean> {
|
||||
// Simplified edit implementation
|
||||
const edits: { range: Range; newText: string }[] = [];
|
||||
const editBuilder = {
|
||||
replace: (range: Range, newText: string) => {
|
||||
edits.push({ range, newText });
|
||||
},
|
||||
};
|
||||
callback(editBuilder);
|
||||
|
||||
// Apply edits in reverse order to avoid offset issues
|
||||
const document = this.document as MockTextDocument;
|
||||
let content = document.getText();
|
||||
edits
|
||||
.sort(
|
||||
(a, b) =>
|
||||
document.offsetAt(b.range.start) - document.offsetAt(a.range.start)
|
||||
)
|
||||
.forEach(edit => {
|
||||
const startOffset = document.offsetAt(edit.range.start);
|
||||
const endOffset = document.offsetAt(edit.range.end);
|
||||
content =
|
||||
content.substring(0, startOffset) +
|
||||
edit.newText +
|
||||
content.substring(endOffset);
|
||||
});
|
||||
|
||||
document._updateContent(content);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1114,6 +1251,33 @@ function createMockExtensionContext(): ExtensionContext {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== Extension API =====
|
||||
|
||||
export interface Extension<T> {
|
||||
id: string;
|
||||
extensionPath: string;
|
||||
isActive: boolean;
|
||||
packageJSON: any;
|
||||
exports: T;
|
||||
activate(): Thenable<T>;
|
||||
}
|
||||
|
||||
class MockExtension<T> implements Extension<T> {
|
||||
constructor(
|
||||
public id: string,
|
||||
public exports: T,
|
||||
public isActive: boolean = true
|
||||
) {}
|
||||
|
||||
extensionPath = '/mock/extension/path';
|
||||
packageJSON = {};
|
||||
|
||||
activate(): Thenable<T> {
|
||||
this.isActive = true;
|
||||
return Promise.resolve(this.exports);
|
||||
}
|
||||
}
|
||||
|
||||
// ===== Foam Commands Lazy Initialization =====
|
||||
|
||||
class TestFoam {
|
||||
@@ -1233,8 +1397,8 @@ async function initializeFoamCommands(foam: Foam): Promise<void> {
|
||||
await foamCommands.openResource(mockContext, foamPromise);
|
||||
await foamCommands.updateGraphCommand(mockContext, foamPromise);
|
||||
await foamCommands.updateWikilinksCommand(mockContext, foamPromise);
|
||||
await foamCommands.generateStandaloneNote(mockContext, foamPromise);
|
||||
await foamCommands.openDailyNoteForDateCommand(mockContext, foamPromise);
|
||||
await foamCommands.convertLinksCommand(mockContext, foamPromise);
|
||||
|
||||
// Commands that only need context
|
||||
await foamCommands.copyWithoutBracketsCommand(mockContext);
|
||||
@@ -1345,8 +1509,10 @@ export const window = {
|
||||
message: string,
|
||||
...items: string[]
|
||||
): Promise<string | undefined> {
|
||||
// Mock implementation - do nothing
|
||||
return undefined;
|
||||
throw new Error(
|
||||
'showErrorMessage called - should be mocked in tests if error handling is expected. Message was: ' +
|
||||
message
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1599,6 +1765,37 @@ export const languages = {
|
||||
},
|
||||
};
|
||||
|
||||
// Extensions namespace
|
||||
export const extensions = {
|
||||
getExtension<T = any>(extensionId: string): Extension<T> | undefined {
|
||||
if (extensionId === 'foam.foam-vscode') {
|
||||
return new MockExtension<any>(
|
||||
extensionId,
|
||||
{
|
||||
get foam() {
|
||||
return TestFoam.getInstance();
|
||||
},
|
||||
},
|
||||
true
|
||||
) as Extension<T>;
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
|
||||
get all(): Extension<any>[] {
|
||||
const foamExtension = new MockExtension<any>(
|
||||
'foam.foam-vscode',
|
||||
{
|
||||
get foam() {
|
||||
return TestFoam.getInstance();
|
||||
},
|
||||
},
|
||||
true
|
||||
);
|
||||
return [foamExtension];
|
||||
},
|
||||
};
|
||||
|
||||
// Env namespace
|
||||
export const env = {
|
||||
__mockClipboard: '',
|
||||
|
||||
@@ -6,6 +6,4 @@ I also want a [[first-document]].
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkrefenrece]: https://foambubble.github.io/
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[first-document]: first-document 'First Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -6,6 +6,4 @@ Here's some [unrelated] content.
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[second-document]: second-document 'Second Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -4,6 +4,4 @@ This is just a link target for now.
|
||||
|
||||
We can use it for other things later if needed.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[first-document]: first-document 'First Document'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
|
||||
|
||||
[](#contributors-)
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
@@ -186,9 +188,7 @@ You can also browse the [docs folder](https://github.com/foambubble/foam/tree/ma
|
||||
|
||||
Foam is licensed under the [MIT license](LICENSE).
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[Backlinking]: docs/user/features/backlinking.md 'Backlinking'
|
||||
[//end]: # 'Autogenerated link references'
|
||||
|
||||
## Contribution Guide
|
||||
|
||||
@@ -384,7 +384,4 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
|
||||
|
||||
This project follows the [all-contributors](https://github.com/all-contributors/all-contributors) specification. Contributions of any kind welcome!
|
||||
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[Backlinking]: docs/user/features/backlinking.md "Backlinking"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[Backlinking]: docs/user/features/backlinking.md 'Backlinks'
|
||||
|
||||
Reference in New Issue
Block a user