Compare commits

...

21 Commits

Author SHA1 Message Date
Riccardo Ferretti
3b5906a1cf v0.28.1 2025-09-25 23:32:29 +02:00
Riccardo Ferretti
dc541dea2a Preparation for next release 2025-09-25 23:32:10 +02:00
Riccardo Ferretti
eb908cb689 added test instructions to CLAUDE 2025-09-25 23:27:57 +02:00
Riccardo
967ff18d8d Sanitize filepath in template before note creation (#1520)
fixes #1216
2025-09-25 17:42:44 +02:00
Riccardo
89298b9652 Use identifier case to further disambiguate notes (#1519)
Fixes #1303
2025-09-25 17:29:42 +02:00
Tenormis
e1694f298b Remove duplicate links between nodes (#1511)
Co-authored-by: tenormis <tenormis@mars.com>
2025-09-25 13:02:24 +02:00
allcontributors[bot]
61961f0c1d add ChThH as a contributor for code (#1515)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2025-09-24 12:30:44 +02:00
Riccardo Ferretti
2822bfaa9e v0.28.0 2025-09-24 12:01:10 +02:00
Riccardo Ferretti
9af4e814ac Preparation for next release 2025-09-24 12:00:49 +02:00
Riccardo Ferretti
f8f2ecbec8 Make Claude more objective 2025-09-24 11:52:04 +02:00
Riccardo
6d4db373bf #1328 Add support for wikilink image styling/sizing properties and title support in md image link (#1514)
* Support for image embed parameters (e.g. ![[img.png|300|center]])

Resolves #1328

Examples:

![[image.png]]              // Original
![[image.png|300]]          // Width only → 300px
![[image.png|50%]]          // Percentage → responsive
![[image.png|300x200]]      // Width × height
![[image.png|20em]]         // With units
![[image.png|300|center]]   // With alignment
![[image.png|300|Alt text]] // With alt text

* Documentation for image styling

* Add support for title in image links (#1262)

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-24 11:50:59 +02:00
Riccardo Ferretti
9149546445 Added documentation guidelines to CLAUDE 2025-09-24 10:15:28 +02:00
Riccardo
4893d55ed3 Added support for tag refactoring (#1513)
* Implemented tag rename command, and renaming via F2 and tree view
* Support for nested tag refactoring
2025-09-23 15:18:09 +02:00
CT Hall
53caa94013 update variable-resolver.ts FOAM_DATE_DAY_ISO (#1512) 2025-09-23 11:43:06 +02:00
Riccardo
eda46ac006 Implements tag navigation and peek functionality (#893) (#1510)
Tag Peek References:
- Users can now peek all references of a tag

Enhanced Tag Search:
- Created new "Foam: Search Tag" ('foam-vscode.search-tag') command for workspace tag search
- Added inline search action button that appears on hover over tag items in tag explorer
- Clicking search icon triggers VS Code's search panel with tag query

FoamTags to use Location instead of URIs
2025-09-17 23:11:02 +02:00
Riccardo Ferretti
37837a314d Add workspace symbol provider for note aliases
Implements support for searching note aliases using VS Code's "Go To Symbol in Workspace" command (Ctrl+T/Cmd+T).
Resolves #1461

- Complements VS Code's built-in markdown symbol support (doesn't add symbols for sections)
- On-the-fly computation without caching for simplicity (will review if performance becomes an issue)
- Subsequence query matching following VS Code recommendations
2025-09-17 16:38:47 +02:00
Riccardo Ferretti
fc084c736e v0.27.7 2025-09-17 12:23:26 +02:00
Riccardo Ferretti
ca5229f557 Added .agent/tasks to gitignore 2025-09-17 12:22:53 +02:00
Riccardo Ferretti
f96282828c Preparation for next release 2025-09-17 12:22:01 +02:00
Riccardo
c863586cd0 Fix #1505 - root-path relative links opening new notes instead of existing files (#1509)
Resolves #1505
When using root-path relative links (e.g., `[text](/path/file.md)`),
Ctrl+clicking would create new notes instead of opening existing files.
This was caused by the markdown provider treating workspace-relative
paths as filesystem absolute paths.

**Changes:**
- Enhanced MarkdownResourceProvider to accept workspace roots context
- Updated link resolution logic to handle workspace-relative paths correctly
- Modified extension initialization to pass VS Code workspace folders
- Enhanced createTestWorkspace() utility to support workspace roots testing

**Behavior:**
- Links starting with `/` now resolve against workspace roots first
- Falls back to existing absolute path behavior when no workspace roots
- Supports multiple workspace scenarios and fragments
- Maintains full backward compatibility
2025-09-17 12:11:35 +02:00
Riccardo
a6c0cc603f Add FOAM_DATE_DAY_ISO template variable for ISO weekday number (1-7, Monday=1)
- Adds FOAM_DATE_DAY_ISO to variable resolver
- Adds dedicated and integrated tests for FOAM_DATE_DAY_ISO
- Updates documentation to describe FOAM_DATE_DAY_ISO usage and behavior
2025-09-15 14:28:31 +00:00
45 changed files with 3430 additions and 116 deletions

View File

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

1
.gitignore vendored
View File

@@ -11,3 +11,4 @@ docs/_site
docs/.sass-cache
docs/.jekyll-metadata
.test-workspace
.agent/tasks

View File

@@ -2,6 +2,10 @@
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Collaboration Principles
**Be honest and objective**: Evaluate all suggestions, ideas, and feedback on their technical merits. Don't be overly complimentary or sycophantic. If something doesn't make sense, doesn't align with best practices, or could be improved, say so directly and constructively. Technical accuracy and project quality take precedence over being agreeable.
## Project overview
Foam is a personal knowledge management and sharing system, built on Visual Studio Code and GitHub. It allows users to organize research, keep re-discoverable notes, write long-form content, and optionally publish it to the web. The main goals are to help users create relationships between thoughts and information, supporting practices like building a "Second Brain" or a "Zettelkasten". Foam is free, open-source, and extensible, giving users ownership and control over their information. The target audience includes individuals interested in personal knowledge management, note-taking, and content creation, particularly those familiar with VS Code and GitHub.
@@ -28,6 +32,7 @@ All the following commands are to be executed from the `packages/foam-vscode` di
Unit tests run in Node.js environment using Jest
Integration tests require VS Code extension host
When running tests, do not provide additional parameters, they are ignored by the custom runner script. You cannot run just a test, you have to run the whole suite.
Unit tests are named `*.test.ts` and integration tests are `*.spec.ts`. These test files live alongside the code in the `src` directory. An integration test is one that has a direct or indirect dependency on `vscode` module.
There is a mock `vscode` module that can be used to run most integration tests without starting VS Code. Tests that can use this mock are start with the line `/* @unit-ready */`.
@@ -178,6 +183,22 @@ When adding to `src/core/`:
The extension supports both Node.js and browser environments via separate build targets.
## Documentation Guidelines
### User Documentation (`docs/user/`)
Documentation in `docs/user/` must be written for non-technical users. The goal is to help novice users quickly start using features, not to explain technical implementation details.
**Writing Guidelines:**
- **Target audience**: Assume users are new to Foam and may not be technical
- **Be concise**: Keep it short and to the point - every sentence must convey useful information
- **Avoid repetition**: Don't repeat the same concept in different words
- **Focus on "how to use"**: Show users what they can do and how to do it, not how it works internally
- **Balance brevity with clarity**: Users won't read verbose documentation, but they need enough information to succeed
- **Use examples**: Show practical use cases rather than abstract descriptions
- **Start with the most common use case**: Lead with what most users will want to do first
# GitHub CLI Integration
To interact with the github repo we will be using the `gh` command.

View File

@@ -278,6 +278,7 @@ Foam is an evolving project and we welcome contributions:
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s-jacob-powell"><img src="https://avatars.githubusercontent.com/u/109111499?v=4?s=60" width="60px;" alt="S. Jacob Powell"/><br /><sub><b>S. Jacob Powell</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=s-jacob-powell" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/figdavi"><img src="https://avatars.githubusercontent.com/u/99026991?v=4?s=60" width="60px;" alt="Davi Figueiredo"/><br /><sub><b>Davi Figueiredo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=figdavi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ChThH"><img src="https://avatars.githubusercontent.com/u/9499483?v=4?s=60" width="60px;" alt="CT Hall"/><br /><sub><b>CT Hall</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ChThH" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -75,3 +75,60 @@ Available options:
- `full-inline`
- `content-card`
- `content-inline`
## Image Sizing
Resize images to make your documents more readable:
```markdown
![[image.png|300]] # 300 pixels wide
![[image.png|50%]] # Half the container width
```
### Common Use Cases
**Make large screenshots readable:**
```markdown
![[screenshot.png|600]]
```
**Create responsive images:**
```markdown
![[diagram.png|70%]]
```
**Size by width and height:**
```markdown
![[image.png|300x200]]
```
### Alignment
Center, left, or right align images:
```markdown
![[image.png|300|center]]
![[image.png|300|left]]
![[image.png|300|right]]
```
### Alt Text
Add descriptions for accessibility:
```markdown
![[chart.png|400|Monthly sales chart]]
```
### Units
- `300` or `300px` - pixels (default)
- `50%` - percentage of container
- `20em` - relative to font size
### Troubleshooting
- Check image path: `![[path/to/image.png|300]]`
- No spaces around pipes: `|300|` not `| 300 |`
- Images only resize in preview mode, not edit mode
- Use lowercase alignment: `center` not `Center`

View File

@@ -240,20 +240,31 @@ Markdown templates can use all the variables available in [VS Code Snippets](htt
In addition, you can also use variables provided by Foam:
| Name | Description |
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new |
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
| `FOAM_TITLE_SAFE` | The title of the note in a file system safe format. If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt. |
| `FOAM_SLUG` | The sluggified title of the note (using the default github slug method). If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt. |
| `FOAM_CURRENT_DIR` | The current editor's directory path. Resolves to the directory of the currently active file, or falls back to workspace root if no editor is active. Useful for creating notes in the current directory context. |
| `FOAM_DATE_*` | `FOAM_DATE_YEAR`, `FOAM_DATE_MONTH`, `FOAM_DATE_WEEK` etc. Foam-specific versions of [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). Prefer these versions over VS Code's. |
| Name | Description |
| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `FOAM_SELECTED_TEXT` | Foam will fill it with selected text when creating a new note, if any text is selected. Selected text will be replaced with a wikilink to the new |
| `FOAM_TITLE` | The title of the note. If used, Foam will prompt you to enter a title for the note. |
| `FOAM_TITLE_SAFE` | The title of the note in a file system safe format. If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt. |
| `FOAM_SLUG` | The sluggified title of the note (using the default github slug method). If used, Foam will prompt you to enter a title for the note unless `FOAM_TITLE` has already caused the prompt. |
| `FOAM_CURRENT_DIR` | The current editor's directory path. Resolves to the directory of the currently active file, or falls back to workspace root if no editor is active. Useful for creating notes in the current directory context. |
| `FOAM_DATE_*` | `FOAM_DATE_YEAR`, `FOAM_DATE_MONTH`, `FOAM_DATE_WEEK`, `FOAM_DATE_DAY_ISO` etc. Foam-specific versions of [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables). Prefer these versions over VS Code's. |
### `FOAM_DATE_*` variables
Foam defines its own set of datetime variables that have a similar behaviour as [VS Code's datetime snippet variables](https://code.visualstudio.com/docs/editor/userdefinedsnippets#_variables).
For example, `FOAM_DATE_YEAR` has the same behaviour as VS Code's `CURRENT_YEAR`, `FOAM_DATE_SECONDS_UNIX` has the same behaviour as `CURRENT_SECONDS_UNIX`, etc.
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_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)
- `FOAM_DATE_DAY_NAME_SHORT`: Short weekday name (e.g. Mon)
- `FOAM_DATE_HOUR`, `FOAM_DATE_MINUTE`, `FOAM_DATE_SECOND`, `FOAM_DATE_SECONDS_UNIX`, etc.
For example, `FOAM_DATE_YEAR` has the same behaviour as VS Code's `CURRENT_YEAR`, `FOAM_DATE_SECONDS_UNIX` has the same behaviour as `CURRENT_SECONDS_UNIX`, etc. `FOAM_DATE_DAY_ISO` returns the ISO weekday number (Monday=1, Sunday=7), which is useful for ISO week date formats like `2025-W37-5`.
By default, prefer using the `FOAM_DATE_` versions. The datetime used to compute the values will be the same for both `FOAM_DATE_` and VS Code's variables, with the exception of the creation notes using the daily note template.
@@ -386,6 +397,6 @@ 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"
[//begin]: # 'Autogenerated link references for markdown compatibility'
[daily-notes]: daily-notes.md 'Daily Notes'
[//end]: # 'Autogenerated link references'

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.27.6"
"version": "0.28.1"
}

View File

@@ -4,6 +4,38 @@ 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.28.1] - 2025-09-25
Fixes and Improvements:
- Removed duplicate links in dataviz graph (#1511 - thanks @Tenormis)
- Use letter case to further disambiguate note identifiers (#1519, #1303)
- Sanitize `filepath` before creating note from template (#1520, #1216)
## [0.28.0] - 2025-09-24
Features:
- Added workspace symbols for note aliases (#1461)
- Added tag navigation and peek (#1510)
- Added support for tag refactoring (#1513)
- Added support for wikilink images styling (#1514)
Fixes and Improvements:
- Added support for image link title attribute (#1514)
- Exposing FOAM_DATE_DAY_ISO variable (#1512 - thanks @ChThH)
## [0.27.7] - 2025-09-13
Features:
- Added `FOAM_DATE_DAY_ISO` template variable for ISO weekday number (1-7, Monday=1)
Fixes and Improvements:
- Fixed root-path relative links opening new notes instead of existing files (#1505)
## [0.27.6] - 2025-09-13
Fixes and Improvements:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.27.6",
"version": "0.28.1",
"license": "MIT",
"publisher": "foam",
"engines": {
@@ -104,6 +104,28 @@
}
],
"menus": {
"view/item/context": [
{
"command": "foam-vscode.search-tag",
"when": "view == foam-vscode.tags-explorer && viewItem == tag",
"group": "inline",
"icon": "$(search)"
},
{
"command": "foam-vscode.rename-tag",
"when": "view == foam-vscode.tags-explorer && viewItem == tag",
"group": "inline",
"icon": "$(edit)"
}
],
"editor/context": [
{
"command": "foam-vscode.rename-tag",
"when": "editorTextFocus && resourceExtname == '.md'",
"group": "foam",
"title": "Rename Tag"
}
],
"view/title": [
{
"command": "foam-vscode.views.connections.show:backlinks",
@@ -352,6 +374,16 @@
"command": "foam-vscode.convert-link-style-incopy",
"title": "Foam: Convert Link Format in Copy"
},
{
"command": "foam-vscode.search-tag",
"title": "Foam: Search Tag",
"icon": "$(search)"
},
{
"command": "foam-vscode.rename-tag",
"title": "Foam: Rename Tag",
"icon": "$(edit)"
},
{
"command": "foam-vscode.views.orphans.group-by:folder",
"title": "Group By Folder",

View File

@@ -1,5 +1,6 @@
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import { FoamTags } from './tags';
import { Location } from './location';
describe('FoamTags', () => {
it('Collects tags from a list of resources', () => {
@@ -23,12 +24,17 @@ describe('FoamTags', () => {
ws.set(pageB);
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(
new Map([
['primary', [pageA.uri, pageB.uri]],
['secondary', [pageA.uri]],
['third', [pageB.uri]],
[
'primary',
[
Location.forObjectWithRange(pageA.uri, pageA.tags[0]),
Location.forObjectWithRange(pageB.uri, pageB.tags[0]),
],
],
['secondary', [Location.forObjectWithRange(pageA.uri, pageA.tags[1])]],
['third', [Location.forObjectWithRange(pageB.uri, pageB.tags[1])]],
])
);
});
@@ -51,7 +57,11 @@ describe('FoamTags', () => {
ws.set(taglessPage);
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
expect(tags.tags).toEqual(
new Map([
['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],
])
);
const newPage = createTestNote({
uri: '/page-b.md',
@@ -62,7 +72,17 @@ describe('FoamTags', () => {
ws.set(newPage);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [page.uri, newPage.uri]]]));
expect(tags.tags).toEqual(
new Map([
[
'primary',
[
Location.forObjectWithRange(page.uri, page.tags[0]),
Location.forObjectWithRange(newPage.uri, newPage.tags[0]),
],
],
])
);
});
it('Replaces the tag when a note is updated with an altered tag', () => {
@@ -78,7 +98,11 @@ describe('FoamTags', () => {
ws.set(page);
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
expect(tags.tags).toEqual(
new Map([
['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],
])
);
const pageEdited = createTestNote({
uri: '/page-a.md',
@@ -90,7 +114,14 @@ describe('FoamTags', () => {
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['new', [page.uri]]]));
expect(tags.tags).toEqual(
new Map([
[
'new',
[Location.forObjectWithRange(pageEdited.uri, pageEdited.tags[0])],
],
])
);
});
it('Updates the metadata of a tag when the note is moved', () => {
@@ -105,7 +136,11 @@ describe('FoamTags', () => {
ws.set(page);
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
expect(tags.tags).toEqual(
new Map([
['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],
])
);
const pageEdited = createTestNote({
uri: '/new-place/page-a.md',
@@ -118,7 +153,14 @@ describe('FoamTags', () => {
ws.set(pageEdited);
tags.update();
expect(tags.tags).toEqual(new Map([['primary', [pageEdited.uri]]]));
expect(tags.tags).toEqual(
new Map([
[
'primary',
[Location.forObjectWithRange(pageEdited.uri, pageEdited.tags[0])],
],
])
);
});
it('Updates the metadata of a tag when a note is deleted', () => {
@@ -133,11 +175,15 @@ describe('FoamTags', () => {
ws.set(page);
const tags = FoamTags.fromWorkspace(ws);
expect(tags.tags).toEqual(new Map([['primary', [page.uri]]]));
expect(tags.tags).toEqual(
new Map([
['primary', [Location.forObjectWithRange(page.uri, page.tags[0])]],
])
);
ws.delete(page.uri);
tags.update();
expect(tags.tags).toEqual(new Map());
expect(tags.tags.size).toEqual(0);
});
});

View File

@@ -1,11 +1,12 @@
import { FoamWorkspace } from './workspace';
import { URI } from './uri';
import { IDisposable } from '../common/lifecycle';
import { debounce } from 'lodash';
import { Emitter } from '../common/event';
import { Tag } from './note';
import { Location } from './location';
export class FoamTags implements IDisposable {
public readonly tags: Map<string, URI[]> = new Map();
public readonly tags: Map<string, Location<Tag>[]> = new Map();
private onDidUpdateEmitter = new Emitter<void>();
onDidUpdate = this.onDidUpdateEmitter.event;
@@ -50,10 +51,10 @@ export class FoamTags implements IDisposable {
update(): void {
this.tags.clear();
for (const resource of this.workspace.resources()) {
for (const tag of new Set(resource.tags.map(t => t.label))) {
const tagMeta = this.tags.get(tag) ?? [];
tagMeta.push(resource.uri);
this.tags.set(tag, tagMeta);
for (const tag of resource.tags) {
const tagLocations = this.tags.get(tag.label) ?? [];
tagLocations.push(Location.forObjectWithRange(resource.uri, tag));
this.tags.set(tag.label, tagLocations);
}
}
this.onDidUpdateEmitter.fire();

View File

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

View File

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

View File

@@ -152,6 +152,94 @@ describe('MarkdownLink', () => {
});
});
describe('parse direct link with title attributes', () => {
it('should parse image with double-quoted title', () => {
const link = parser.parse(
getRandomURI(),
`![alt text](image.jpg "Title text")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('image.jpg');
expect(parsed.alias).toEqual('alt text');
expect(parsed.section).toEqual('');
});
it('should parse image with single-quoted title', () => {
const link = parser.parse(
getRandomURI(),
`![alt text](image.jpg 'Title text')`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('image.jpg');
expect(parsed.alias).toEqual('alt text');
expect(parsed.section).toEqual('');
});
it('should handle sections with titles', () => {
const link = parser.parse(
getRandomURI(),
`![alt text](image.jpg#section "Title text")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('image.jpg');
expect(parsed.section).toEqual('section');
expect(parsed.alias).toEqual('alt text');
});
it('should handle URLs with spaces in titles', () => {
const link = parser.parse(
getRandomURI(),
`![alt](path/to/file.jpg "Title with spaces")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('path/to/file.jpg');
expect(parsed.alias).toEqual('alt');
expect(parsed.section).toEqual('');
});
it('should maintain compatibility with titleless images', () => {
const link = parser.parse(getRandomURI(), `![alt text](image.jpg)`)
.links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('image.jpg');
expect(parsed.alias).toEqual('alt text');
expect(parsed.section).toEqual('');
});
it('should handle complex URLs with titles', () => {
const link = parser.parse(
getRandomURI(),
`![alt](path/to/image.jpg "Complex title with spaces")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('path/to/image.jpg');
expect(parsed.alias).toEqual('alt');
expect(parsed.section).toEqual('');
});
it('should parse regular links with titles', () => {
const link = parser.parse(
getRandomURI(),
`[link text](document.md "Link title")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('document.md');
expect(parsed.alias).toEqual('link text');
expect(parsed.section).toEqual('');
});
it('should handle titles with special characters', () => {
const link = parser.parse(
getRandomURI(),
`![alt](image.jpg "Title with special chars")`
).links[0];
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('image.jpg');
expect(parsed.alias).toEqual('alt');
expect(parsed.section).toEqual('');
});
});
describe('rename wikilink', () => {
it('should rename the target only', () => {
const link = parser.parse(

View File

@@ -6,7 +6,7 @@ export abstract class MarkdownLink {
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
);
private static directLinkRegex = new RegExp(
/\[(.*)\]\(<?([^#>]*)?#?([^\]>]+)?>?\)/
/\[(.*)\]\(<?([^#>]*?)(?:#([^>\s"'()]*))?(?:\s+(?:"[^"]*"|'[^']*'))?>?\)/
);
public static analyzeLink(link: ResourceLink) {

View File

@@ -309,6 +309,101 @@ describe('Link resolution', () => {
expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);
expect(noteD.links).toEqual([]);
});
describe('Workspace-relative paths (root-path relative)', () => {
it('should resolve workspace-relative paths starting with /', () => {
const noteA = createTestNote({
uri: '/workspace/dir1/page-a.md',
links: [{ to: '/dir2/page-b.md' }],
});
const noteB = createTestNote({
uri: '/workspace/dir2/page-b.md',
});
const ws = createTestWorkspace([URI.file('/workspace')]);
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should resolve workspace-relative paths with nested directories', () => {
const noteA = createTestNote({
uri: '/workspace/project/notes/page-a.md',
links: [{ to: '/project/assets/image.png' }],
});
const assetB = createTestNote({
uri: '/workspace/project/assets/image.png',
});
const ws = createTestWorkspace([URI.file('/workspace')]);
ws.set(noteA).set(assetB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(assetB.uri);
});
it('should handle workspace-relative paths with fragments', () => {
const noteA = createTestNote({
uri: '/workspace/dir1/page-a.md',
links: [{ to: '/dir2/page-b.md#section' }],
});
const noteB = createTestNote({
uri: '/workspace/dir2/page-b.md',
});
const ws = createTestWorkspace([URI.file('/workspace')]);
ws.set(noteA).set(noteB);
const resolved = ws.resolveLink(noteA, noteA.links[0]);
expect(resolved).toEqual(noteB.uri.with({ fragment: 'section' }));
});
it('should fall back to placeholder for non-existent workspace-relative paths', () => {
const noteA = createTestNote({
uri: '/workspace/dir1/page-a.md',
links: [{ to: '/dir2/non-existent.md' }],
});
const ws = createTestWorkspace([URI.file('/workspace')]);
ws.set(noteA);
const resolved = ws.resolveLink(noteA, noteA.links[0]);
expect(resolved.isPlaceholder()).toBe(true);
expect(resolved.path).toEqual('/workspace/dir2/non-existent.md');
});
it('should work with multiple workspace roots', () => {
const noteA = createTestNote({
uri: '/workspace1/dir1/page-a.md',
links: [{ to: '/shared/page-b.md' }],
});
const noteB = createTestNote({
uri: '/workspace2/shared/page-b.md',
});
const ws = createTestWorkspace([
URI.file('/workspace1'),
URI.file('/workspace2'),
]);
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should preserve existing absolute path behavior when no workspace roots provided', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: '/path/to/another/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/another/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
// Default provider without workspace roots should work as before
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
});
});
});

View File

@@ -20,7 +20,8 @@ export class MarkdownResourceProvider implements ResourceProvider {
constructor(
private readonly dataStore: IDataStore,
private readonly parser: ResourceParser,
public readonly noteExtensions: string[] = ['.md']
public readonly noteExtensions: string[] = ['.md'],
private readonly workspaceRoots: URI[] = []
) {}
supports(uri: URI) {
@@ -82,16 +83,50 @@ export class MarkdownResourceProvider implements ResourceProvider {
break;
}
case 'link': {
// force ambiguous links to be treated as relative
const path =
target.startsWith('/') ||
target.startsWith('./') ||
target.startsWith('../')
? target
: './' + target;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
let path: string;
let foundResource: Resource | null = null;
if (target.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 absolutePath = workspaceRoot.joinPath(candidatePath);
const found = workspace.find(absolutePath);
if (found) {
foundResource = found;
break;
}
}
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 absolutePath = firstRoot.joinPath(candidatePath);
targetUri = URI.placeholder(absolutePath.path);
} else {
targetUri = foundResource.uri;
}
} else {
// No workspace roots provided, fall back to existing behavior
path = target;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
}
} else {
// Handle relative paths and non-root paths
path =
target.startsWith('./') || target.startsWith('../')
? target
: './' + target;
targetUri =
workspace.find(path, resource.uri)?.uri ??
URI.placeholder(resource.uri.resolve(path).path);
}
if (section && !targetUri.isPlaceholder()) {
targetUri = targetUri.with({ fragment: section });
}

View File

@@ -0,0 +1,611 @@
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
import { FoamTags } from '../model/tags';
import { TagEdit } from './tag-edit';
import { Range } from '../model/range';
import { Position } from '../model/position';
import { URI } from '../model/uri';
describe('TagEdit', () => {
describe('createRenameTagEdits', () => {
it('should generate edits for all occurrences of a tag', () => {
const ws = createTestWorkspace();
const pageA = createTestNote({
uri: '/page-a.md',
title: 'Page A',
tags: ['oldtag', 'anothertag'],
});
// Manually set the ranges for testing
pageA.tags[0].range = Range.create(0, 5, 0, 11);
pageA.tags[1].range = Range.create(1, 5, 1, 15);
const pageB = createTestNote({
uri: '/page-b.md',
title: 'Page B',
tags: ['oldtag'],
});
// Manually set the range for testing
pageB.tags[0].range = Range.create(2, 10, 2, 16);
ws.set(pageA);
ws.set(pageB);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createRenameTagEdits(foamTags, 'oldtag', 'newtag');
expect(result.totalOccurrences).toBe(2);
expect(result.edits).toHaveLength(2);
// Check edits - should contain one edit for each page
const pageAEdit = result.edits.find(
e => e.uri.toString() === 'file:///page-a.md'
);
expect(pageAEdit).toBeDefined();
expect(pageAEdit!.edit).toEqual({
range: Range.create(0, 5, 0, 11),
newText: 'newtag',
});
const pageBEdit = result.edits.find(
e => e.uri.toString() === 'file:///page-b.md'
);
expect(pageBEdit).toBeDefined();
expect(pageBEdit!.edit).toEqual({
range: Range.create(2, 10, 2, 16),
newText: 'newtag',
});
});
it('should return empty result when tag does not exist', () => {
const ws = createTestWorkspace();
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createRenameTagEdits(
foamTags,
'nonexistent',
'newtag'
);
expect(result.totalOccurrences).toBe(0);
expect(result.edits).toHaveLength(0);
});
it('should handle multiple edits in the same file', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['duplicatetag', 'duplicatetag'],
});
// Manually set the ranges for testing
page.tags[0].range = Range.create(0, 5, 0, 17);
page.tags[1].range = Range.create(5, 10, 5, 22);
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createRenameTagEdits(
foamTags,
'duplicatetag',
'newtag'
);
expect(result.totalOccurrences).toBe(2);
expect(result.edits).toHaveLength(2);
// Filter edits for the specific page
const pageEdits = result.edits.filter(e => e.uri.isEqual(page.uri));
expect(pageEdits).toHaveLength(2);
expect(pageEdits.map(e => e.edit)).toEqual([
{
range: Range.create(0, 5, 0, 17),
newText: 'newtag',
},
{
range: Range.create(5, 10, 5, 22),
newText: 'newtag',
},
]);
});
it('should preserve # prefix for hashtag-style tags', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['hashtag'],
});
// Simulate a hashtag range that includes the # prefix (length = label + 1)
page.tags[0].range = Range.create(0, 5, 0, 13); // "#hashtag" = 8 chars
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createRenameTagEdits(
foamTags,
'hashtag',
'newtag'
);
expect(result.totalOccurrences).toBe(1);
expect(result.edits).toHaveLength(1);
const pageEdit = result.edits[0];
expect(pageEdit.uri.toString()).toBe('file:///page.md');
expect(pageEdit.edit).toEqual({
range: Range.create(0, 5, 0, 13),
newText: '#newtag', // Should include # prefix
});
});
it('should not add # prefix for YAML-style tags', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['yamltag'],
});
// Simulate a YAML tag range that does not include # prefix (length = label only)
page.tags[0].range = Range.create(0, 5, 0, 12); // "yamltag" = 7 chars
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createRenameTagEdits(
foamTags,
'yamltag',
'newtag'
);
expect(result.totalOccurrences).toBe(1);
expect(result.edits).toHaveLength(1);
const pageEdit = result.edits[0];
expect(pageEdit.uri.toString()).toBe('file:///page.md');
expect(pageEdit.edit).toEqual({
range: Range.create(0, 5, 0, 12),
newText: 'newtag', // Should not include # prefix
});
});
});
describe('validateTagRename', () => {
it('should accept valid tag rename', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'newtag');
expect(result.isValid).toBe(true);
expect(result.message).toBeUndefined();
});
it('should reject rename of non-existent tag', () => {
const ws = createTestWorkspace();
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(
foamTags,
'nonexistent',
'newtag'
);
expect(result.isValid).toBe(false);
expect(result.message).toContain('does not exist');
});
it('should reject empty new tag name', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(foamTags, 'oldtag', '');
expect(result.isValid).toBe(false);
expect(result.message).toContain('cannot be empty');
});
it('should detect merge when renaming to existing tag', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag', 'existingtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(
foamTags,
'oldtag',
'existingtag'
);
expect(result.isValid).toBe(true);
expect(result.isMerge).toBe(true);
expect(result.sourceOccurrences).toBe(1);
expect(result.targetOccurrences).toBe(1);
expect(result.message).toContain('merge');
expect(result.message).toContain('oldtag');
expect(result.message).toContain('existingtag');
});
it('should reject tag names with spaces', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'new tag');
expect(result.isValid).toBe(false);
expect(result.message).toContain('Invalid tag label');
});
it('should handle new tag name with # prefix', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(foamTags, 'oldtag', '#newtag');
expect(result.isValid).toBe(true);
expect(result.isMerge).toBe(false);
expect(result.sourceOccurrences).toBe(1);
expect(result.targetOccurrences).toBe(0);
expect(result.message).toBeUndefined();
});
it('should reject renaming to same tag name', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['oldtag'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.validateTagRename(foamTags, 'oldtag', 'oldtag');
expect(result.isValid).toBe(false);
expect(result.isMerge).toBe(false);
expect(result.sourceOccurrences).toBe(1);
expect(result.targetOccurrences).toBe(1);
expect(result.message).toContain('same as the current name');
});
});
describe('findChildTags', () => {
it('should find direct child tags', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['project', 'project/frontend', 'project/backend', 'other'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const childTags = TagEdit.findChildTags(foamTags, 'project');
expect(childTags).toEqual(['project/backend', 'project/frontend']);
});
it('should find nested child tags', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: [
'project',
'project/frontend',
'project/frontend/react',
'project/backend',
'project/backend/api',
'other',
],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const childTags = TagEdit.findChildTags(foamTags, 'project');
expect(childTags).toEqual([
'project/backend',
'project/backend/api',
'project/frontend',
'project/frontend/react',
]);
});
it('should return empty array when no child tags exist', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['project', 'other', 'standalone'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const childTags = TagEdit.findChildTags(foamTags, 'project');
expect(childTags).toEqual([]);
});
it('should not return partial matches', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['project', 'projectile', 'project-old'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const childTags = TagEdit.findChildTags(foamTags, 'project');
expect(childTags).toEqual([]);
});
});
describe('createHierarchicalRenameEdits', () => {
it('should rename parent and all child tags', () => {
const ws = createTestWorkspace();
const pageA = createTestNote({
uri: '/page-a.md',
title: 'Page A',
tags: ['project', 'project/frontend'],
});
const pageB = createTestNote({
uri: '/page-b.md',
title: 'Page B',
tags: ['project/backend', 'other'],
});
ws.set(pageA);
ws.set(pageB);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createHierarchicalRenameEdits(
foamTags,
'project',
'work'
);
expect(result.totalOccurrences).toBe(3); // project, project/frontend, project/backend
expect(result.edits).toHaveLength(3);
// Check that all expected tags are renamed
const editedTags = result.edits.map(edit => edit.edit.newText);
expect(editedTags).toContain('work');
expect(editedTags).toContain('work/frontend');
expect(editedTags).toContain('work/backend');
});
it('should handle nested hierarchies correctly', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['project', 'project/frontend', 'project/frontend/react'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createHierarchicalRenameEdits(
foamTags,
'project',
'work'
);
expect(result.totalOccurrences).toBe(3);
const editedTags = result.edits.map(edit => edit.edit.newText);
expect(editedTags).toContain('work');
expect(editedTags).toContain('work/frontend');
expect(editedTags).toContain('work/frontend/react');
});
it('should work when parent tag has no children', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['standalone', 'other'],
});
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
const result = TagEdit.createHierarchicalRenameEdits(
foamTags,
'standalone',
'single'
);
expect(result.totalOccurrences).toBe(1);
expect(result.edits).toHaveLength(1);
expect(result.edits[0].edit.newText).toBe('single');
});
});
describe('getTagAtPosition', () => {
it('should find tag at exact position', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['testtag'],
});
// Manually set the range for testing
page.tags[0].range = Range.create(0, 5, 0, 12);
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
// Test positions within the tag range
const pageUri = URI.parse('file:///page.md', 'file');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 5))
).toBe('testtag');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 8))
).toBe('testtag');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 12))
).toBe('testtag');
// Test positions outside the tag range
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 4))
).toBeUndefined();
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 13))
).toBeUndefined();
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 5))
).toBeUndefined();
});
it('should return undefined for non-existent file', () => {
const ws = createTestWorkspace();
const foamTags = FoamTags.fromWorkspace(ws);
const nonexistentUri = URI.parse('file:///nonexistent.md', 'file');
expect(
TagEdit.getTagAtPosition(
foamTags,
nonexistentUri,
Position.create(0, 5)
)
).toBeUndefined();
});
it('should handle multiple tags and return the correct one', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['firsttag', 'secondtag'],
});
// Manually set the ranges for testing
page.tags[0].range = Range.create(0, 5, 0, 13);
page.tags[1].range = Range.create(0, 20, 0, 29);
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
// Should return the correct tag for each position
const pageUri = URI.parse('file:///page.md', 'file');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 8))
).toBe('firsttag');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 25))
).toBe('secondtag');
// Position between tags should return undefined
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(0, 15))
).toBeUndefined();
});
it('should handle multiline tags', () => {
const ws = createTestWorkspace();
const page = createTestNote({
uri: '/page.md',
title: 'Page',
tags: ['multilinetag'],
});
// Manually set the range for testing
page.tags[0].range = Range.create(1, 10, 3, 5);
ws.set(page);
const foamTags = FoamTags.fromWorkspace(ws);
// Should find tag on different lines within the range
const pageUri = URI.parse('file:///page.md', 'file');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 15))
).toBe('multilinetag');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(2, 0))
).toBe('multilinetag');
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(3, 3))
).toBe('multilinetag');
// Should not find tag outside the range
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(1, 5))
).toBeUndefined();
expect(
TagEdit.getTagAtPosition(foamTags, pageUri, Position.create(3, 10))
).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,284 @@
import { FoamTags } from '../model/tags';
import { TextEdit, WorkspaceTextEdit } from './text-edit';
import { Location } from '../model/location';
import { Tag } from '../model/note';
import { URI } from '../model/uri';
import { Range } from '../model/range';
import { Position } from '../model/position';
import { WORD_REGEX } from '../utils/hashtags';
/**
* Result object containing all information needed to perform a tag rename operation.
*/
export interface TagEditResult {
/**
* Array of workspace text edits to perform the tag rename operation.
*/
edits: WorkspaceTextEdit[];
/**
* Total number of tag occurrences that will be renamed across all files.
*/
totalOccurrences: number;
}
/**
* Utility class for performing tag editing operations in Foam workspaces.
* Provides functionality to rename tags across multiple files while maintaining
* consistency and data integrity.
*/
export abstract class TagEdit {
/**
* Generate text edits to rename a tag across the workspace.
*
* @param foamTags The FoamTags instance containing all tag locations
* @param oldTagLabel The current tag label to rename (without # prefix)
* @param newTagLabel The new tag label (without # prefix)
* @returns TagEditResult containing all necessary workspace text edits
*/
public static createRenameTagEdits(
foamTags: FoamTags,
oldTagLabel: string,
newTagLabel: string
): TagEditResult {
const tagLocations = foamTags.tags.get(oldTagLabel) ?? [];
const workspaceEdits: WorkspaceTextEdit[] = [];
for (const location of tagLocations) {
const textEdit = this.createSingleTagEdit(
location,
oldTagLabel,
newTagLabel
);
workspaceEdits.push({
uri: location.uri,
edit: textEdit,
});
}
return {
edits: workspaceEdits,
totalOccurrences: tagLocations.length,
};
}
/**
* Create a single text edit for a tag location.
*
* @param location The location of the tag to rename
* @param oldTagLabel The current tag label to determine original format
* @param newTagLabel The new tag label to replace with
* @returns TextEdit for this specific tag occurrence
*/
private static createSingleTagEdit(
location: Location<Tag>,
oldTagLabel: string,
newTagLabel: string
): TextEdit {
const range = location.range;
const rangeLength = range.end.character - range.start.character;
// If range length is tag label length + 1, it's a hashtag (includes #)
// If range length equals tag label length, it's a YAML tag (no #)
const isHashtag = rangeLength === oldTagLabel.length + 1;
const newText = isHashtag ? `#${newTagLabel}` : newTagLabel;
return {
range: location.range,
newText,
};
}
/**
* Validate if a tag rename operation is safe and allowed.
*
* @param foamTags The FoamTags instance containing current tag information
* @param oldTagLabel The tag being renamed (must exist in workspace)
* @param newTagLabel The proposed new tag label (will be cleaned of # prefix)
* @returns Validation result with merge information and statistics
*/
public static validateTagRename(
foamTags: FoamTags,
oldTagLabel: string,
newTagLabel: string
): {
isValid: boolean;
isMerge: boolean;
sourceOccurrences: number;
targetOccurrences: number;
message?: string;
} {
const sourceOccurrences = foamTags.tags.get(oldTagLabel)?.length ?? 0;
// Check if old tag exists
if (!foamTags.tags.has(oldTagLabel)) {
return {
isValid: false,
isMerge: false,
sourceOccurrences: 0,
targetOccurrences: 0,
message: `Tag "${oldTagLabel}" does not exist in the workspace.`,
};
}
// Clean the new tag label (remove # if present)
const cleanNewLabel = newTagLabel?.startsWith('#')
? newTagLabel.substring(1)
: newTagLabel;
// Check if new tag label is empty or invalid
if (!cleanNewLabel || cleanNewLabel.trim() === '') {
return {
isValid: false,
isMerge: false,
sourceOccurrences,
targetOccurrences: 0,
message: 'New tag label cannot be empty.',
};
}
// Check for invalid characters in tag label
const match = cleanNewLabel.match(WORD_REGEX);
if (!match || match[0] !== cleanNewLabel) {
return {
isValid: false,
isMerge: false,
sourceOccurrences,
targetOccurrences: 0,
message: 'Invalid tag label.',
};
}
// Check if renaming to same tag (no-op)
if (cleanNewLabel === oldTagLabel) {
return {
isValid: false,
isMerge: false,
sourceOccurrences,
targetOccurrences: sourceOccurrences,
message: 'New tag name is the same as the current name.',
};
}
const targetOccurrences = foamTags.tags.get(cleanNewLabel)?.length ?? 0;
const isMerge = foamTags.tags.has(cleanNewLabel);
return {
isValid: true,
isMerge: isMerge,
sourceOccurrences,
targetOccurrences,
message: isMerge
? `This will merge "${oldTagLabel}" (${sourceOccurrences} occurrence${
sourceOccurrences !== 1 ? 's' : ''
}) into "${cleanNewLabel}" (${targetOccurrences} occurrence${
targetOccurrences !== 1 ? 's' : ''
})`
: undefined,
};
}
/**
* Find all child tags for a given parent tag.
*
* This method searches for tags that start with the parent tag followed by
* a forward slash, indicating they are hierarchical children.
*
* @param foamTags The FoamTags instance containing all tag information
* @param parentTag The parent tag to find children for (e.g., "project")
* @returns Array of child tag labels (e.g., ["project/frontend", "project/backend"])
*/
public static findChildTags(foamTags: FoamTags, parentTag: string): string[] {
const childTags: string[] = [];
const parentPrefix = parentTag + '/';
for (const [tagLabel] of foamTags.tags) {
if (tagLabel.startsWith(parentPrefix)) {
childTags.push(tagLabel);
}
}
return childTags.sort();
}
/**
* Create text edits to rename a parent tag and all its children hierarchically.
*
* This method performs a comprehensive rename operation that updates both
* the parent tag and all child tags, maintaining the hierarchical structure
* with the new parent name.
*
* @param foamTags The FoamTags instance containing all tag locations
* @param oldParentTag The current parent tag label (without # prefix)
* @param newParentTag The new parent tag label (without # prefix)
* @returns TagEditResult containing all necessary workspace text edits
*/
public static createHierarchicalRenameEdits(
foamTags: FoamTags,
oldParentTag: string,
newParentTag: string
): TagEditResult {
const allEdits: WorkspaceTextEdit[] = [];
let totalOccurrences = 0;
// Rename the parent tag itself
const parentResult = this.createRenameTagEdits(
foamTags,
oldParentTag,
newParentTag
);
allEdits.push(...parentResult.edits);
totalOccurrences += parentResult.totalOccurrences;
// Find and rename all child tags
const childTags = this.findChildTags(foamTags, oldParentTag);
for (const childTag of childTags) {
// Replace the parent portion with the new parent name
const newChildTag = childTag.replace(
oldParentTag + '/',
newParentTag + '/'
);
const childResult = this.createRenameTagEdits(
foamTags,
childTag,
newChildTag
);
allEdits.push(...childResult.edits);
totalOccurrences += childResult.totalOccurrences;
}
return {
edits: allEdits,
totalOccurrences,
};
}
/**
* Find the tag at a specific position in a document.
*
* @param foamTags The FoamTags instance containing all tag location data
* @param uri The URI of the file to search in
* @param position The position in the document (line and character)
* @returns The tag label if a tag is found at the position, undefined otherwise
*/
public static getTagAtPosition(
foamTags: FoamTags,
uri: URI,
position: Position
): string | undefined {
// Search through all tags to find one that contains the given position
for (const [tagLabel, locations] of foamTags.tags) {
for (const location of locations) {
if (!location.uri.isEqual(uri)) {
continue;
}
if (Range.containsPosition(location.range, position)) {
return tagLabel;
}
}
}
return undefined;
}
}

View File

@@ -1,6 +1,7 @@
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { Range } from '../model/range';
import { URI } from '../model/uri';
export interface TextEdit {
range: Range;
@@ -42,3 +43,16 @@ const getOffset = (
}
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};
/**
* A text edit with workspace context, combining a URI location with the edit operation.
*
* This interface uses composition to pair a text edit with its file location,
* providing a self-contained unit for workspace-wide text modifications.
*/
export interface WorkspaceTextEdit {
/** The URI of the file where this edit should be applied */
uri: URI;
/** The text edit operation to perform */
edit: TextEdit;
}

View File

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

View File

@@ -104,7 +104,6 @@ describe('Daily note creation and template processing', () => {
const targetDate = new Date(2021, 8, 12); // September 12, 2021
const template = await createFile(
// eslint-disable-next-line no-template-curly-in-string
`# \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}
Year: \${FOAM_DATE_YEAR} (short: \${FOAM_DATE_YEAR_SHORT})

View File

@@ -4,6 +4,7 @@ import { workspace, ExtensionContext, window, commands } from 'vscode';
import { MarkdownResourceProvider } from './core/services/markdown-provider';
import { bootstrap } from './core/model/foam';
import { Logger } from './core/utils/log';
import { fromVsCodeUri } from './utils/vsc-utils';
import { features } from './features';
import { VsCodeOutputLogger, exposeLogger } from './services/logging';
@@ -51,10 +52,16 @@ export async function activate(context: ExtensionContext) {
const { notesExtensions, defaultExtension } = getNotesExtensions();
// Get workspace roots for workspace-relative path resolution
const workspaceRoots =
workspace.workspaceFolders?.map(folder => fromVsCodeUri(folder.uri)) ??
[];
const markdownProvider = new MarkdownResourceProvider(
dataStore,
parser,
notesExtensions
notesExtensions,
workspaceRoots
);
const attachmentExtConfig = getAttachmentsExtensions();

View File

@@ -11,3 +11,5 @@ 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';

View File

@@ -0,0 +1,324 @@
import * as vscode from 'vscode';
import { Foam } from '../../core/model/foam';
import { TagEdit } from '../../core/services/tag-edit';
import { TagItem } from '../panels/tags-explorer';
import { fromVsCodeUri, toVsCodeWorkspaceEdit } from '../../utils/vsc-utils';
import { Logger } from '../../core/utils/log';
import { Position } from '../../core/model/position';
/**
* Command definition for the tag rename functionality.
*
* This command provides workspace-wide tag renaming capabilities with multiple
* invocation methods: command palette, context menus, and programmatic calls.
*/
export const RENAME_TAG_COMMAND = {
/** VS Code command identifier */
command: 'foam-vscode.rename-tag',
/** Display name shown in command palette */
title: 'Foam: Rename Tag',
};
/**
* Activates the rename tag command feature.
*
* Registers the rename tag command with VS Code and sets up error handling.
* The command supports multiple parameter combinations for different use cases.
*
* @param context VS Code extension context for registering disposables
* @param foamPromise Promise that resolves to the initialized Foam instance
*/
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
context.subscriptions.push(
vscode.commands.registerCommand(
RENAME_TAG_COMMAND.command,
async (tagLabelOrItem?: string | TagItem, newTagName?: string) => {
try {
await executeRenameTag(foam, tagLabelOrItem, newTagName);
} catch (error) {
Logger.error('Error executing rename tag command:', error);
vscode.window.showErrorMessage(
`Failed to rename tag: ${error.message}`
);
}
}
)
);
}
/**
* Execute the tag rename operation with flexible parameter handling.
*
* This function handles the complete tag rename workflow:
* 1. Determine which tag to rename (from parameters, cursor position, or user selection)
* 2. Get the new tag name (from parameters or user input)
* 3. Validate the rename operation
* 4. Apply the changes across the workspace
*
* @param foam The Foam instance containing workspace and tag information
* @param tagLabelOrItem Optional tag to rename (string label or TagItem from explorer)
* @param newTagName Optional new name for the tag
*
* @example
* ```typescript
* // Rename specific tag programmatically
* await executeRenameTag(foam, 'oldtag', 'newtag');
*
* // Interactive rename with tag picker
* await executeRenameTag(foam);
*
* // Rename tag from Tags Explorer context
* await executeRenameTag(foam, tagItem);
* ```
*/
async function executeRenameTag(
foam: Foam,
tagLabelOrItem?: string | TagItem,
newTagName?: string
): Promise<void> {
let tagLabel: string | undefined;
// Determine the tag to rename
if (typeof tagLabelOrItem === 'string') {
tagLabel = tagLabelOrItem;
} else if (
tagLabelOrItem &&
typeof tagLabelOrItem === 'object' &&
'tag' in tagLabelOrItem
) {
tagLabel = tagLabelOrItem.tag;
} else {
// Try to detect tag from current cursor position
const activeEditor = vscode.window.activeTextEditor;
if (activeEditor && activeEditor.document.languageId === 'markdown') {
const vsPosition = activeEditor.selection.active;
const fileUri = fromVsCodeUri(activeEditor.document.uri);
const position = Position.create(vsPosition.line, vsPosition.character);
tagLabel = TagEdit.getTagAtPosition(foam.tags, fileUri, position);
}
}
// If we still don't have a tag, show picker
if (!tagLabel) {
const allTags = Array.from(foam.tags.tags.keys()).sort();
if (allTags.length === 0) {
vscode.window.showInformationMessage('No tags found in workspace.');
return;
}
tagLabel = await vscode.window.showQuickPick(allTags, {
title: 'Select a tag to rename',
placeHolder: 'Choose a tag to rename...',
});
if (!tagLabel) {
return; // User cancelled
}
}
// Get the new tag name from user or use provided parameter
let finalNewTagName = newTagName;
// If newTagName was provided, validate it first
if (finalNewTagName) {
const cleanValue = finalNewTagName.startsWith('#')
? finalNewTagName.substring(1)
: finalNewTagName;
const validation = TagEdit.validateTagRename(
foam.tags,
tagLabel!,
cleanValue
);
if (!validation.isValid) {
throw new Error(validation.message);
}
// Handle merge confirmation if needed
if (validation.isMerge) {
const confirmed = await vscode.window.showWarningMessage(
`Tag "${cleanValue}" already exists (${
validation.targetOccurrences
} occurrence${
validation.targetOccurrences !== 1 ? 's' : ''
}). Merge "${tagLabel}" (${validation.sourceOccurrences} occurrence${
validation.sourceOccurrences !== 1 ? 's' : ''
}) into it?`,
{ modal: true },
'Merge Tags'
);
if (confirmed !== 'Merge Tags') {
throw new Error('Tag merge cancelled by user');
}
}
}
if (!finalNewTagName) {
const currentOccurrences = foam.tags.tags.get(tagLabel)?.length ?? 0;
finalNewTagName = await vscode.window.showInputBox({
title: `Rename tag "${tagLabel}"`,
prompt: `Enter new name for tag "${tagLabel}" (${currentOccurrences} occurrence${
currentOccurrences !== 1 ? 's' : ''
})`,
value: tagLabel,
validateInput: (value: string) => {
const validation = TagEdit.validateTagRename(
foam.tags,
tagLabel!,
value
);
if (!validation.isValid) {
return validation.message;
}
// Show merge information but allow the input
if (validation.isMerge) {
return {
message: `Will merge into existing tag: ${value} - ${
validation.targetOccurrences
} occurrence${validation.targetOccurrences !== 1 ? 's' : ''}`,
severity: vscode.InputBoxValidationSeverity.Info,
};
}
return undefined;
},
});
if (!finalNewTagName) {
return; // User cancelled
}
}
// Clean the new name
const cleanNewName = finalNewTagName.startsWith('#')
? finalNewTagName.substring(1)
: finalNewTagName;
// Final validation and merge confirmation for input box flow
const finalValidation = TagEdit.validateTagRename(
foam.tags,
tagLabel,
cleanNewName
);
if (!finalValidation.isValid) {
throw new Error(finalValidation.message);
}
// Check for child tags and offer hierarchical rename
const childTags = TagEdit.findChildTags(foam.tags, tagLabel);
const hasChildren = childTags.length > 0;
let useHierarchicalRename = false;
if (hasChildren) {
const childList = childTags.map(tag => `${tag}`).join('\n');
const choice = await vscode.window.showWarningMessage(
`Tag "${tagLabel}" has ${childTags.length} child tag${
childTags.length !== 1 ? 's' : ''
}:\n\n${childList}\n\nHow would you like to proceed?`,
{ modal: true },
'Rename Only Parent',
'Rename All (Parent + Children)'
);
if (choice === 'Rename All (Parent + Children)') {
useHierarchicalRename = true;
} else if (choice !== 'Rename Only Parent') {
return; // User cancelled
}
}
// Handle merge confirmation if needed (for input box flow)
if (finalValidation.isMerge) {
const confirmed = await vscode.window.showWarningMessage(
`Tag "${cleanNewName}" already exists (${
finalValidation.targetOccurrences
} occurrence${
finalValidation.targetOccurrences !== 1 ? 's' : ''
}). Merge "${tagLabel}" (${finalValidation.sourceOccurrences} occurrence${
finalValidation.sourceOccurrences !== 1 ? 's' : ''
}) into it?`,
{ modal: true },
'Merge Tags'
);
if (confirmed !== 'Merge Tags') {
return; // User cancelled merge
}
}
// Perform the rename
await performTagRename(foam, tagLabel, cleanNewName, useHierarchicalRename);
}
/**
* Perform the actual tag rename operation by applying workspace edits.
*
* This internal function generates all necessary text edits and applies them
* to the workspace. It provides user feedback through VS Code notifications
* and logs the operation results.
*
* @param foam The Foam instance containing workspace and tag information
* @param oldTagLabel The current tag label to be renamed
* @param newTagLabel The new tag label to rename to
* @param useHierarchicalRename Whether to rename child tags as well
* @throws Error if workspace edits cannot be applied
* @internal
*/
async function performTagRename(
foam: Foam,
oldTagLabel: string,
newTagLabel: string,
useHierarchicalRename: boolean = false
): Promise<void> {
// Generate all the edits - use hierarchical method if requested
const tagEditResult = useHierarchicalRename
? TagEdit.createHierarchicalRenameEdits(foam.tags, oldTagLabel, newTagLabel)
: TagEdit.createRenameTagEdits(foam.tags, oldTagLabel, newTagLabel);
if (tagEditResult.totalOccurrences === 0) {
vscode.window.showWarningMessage(
`No occurrences of tag "${oldTagLabel}" found.`
);
return;
}
// Convert to VS Code WorkspaceEdit
const workspaceEdit = toVsCodeWorkspaceEdit(
tagEditResult.edits,
foam.workspace
);
// Apply the edits
const success = await vscode.workspace.applyEdit(workspaceEdit);
if (success) {
// Calculate unique file count from workspace edits
const uniqueFiles = new Set(
tagEditResult.edits.map(edit => edit.uri.toString())
).size;
const occurrences = tagEditResult.totalOccurrences;
Logger.info(
`Successfully renamed tag "${oldTagLabel}" to "${newTagLabel}" (${occurrences} occurrences across ${uniqueFiles} files)`
);
vscode.window.showInformationMessage(
`Renamed tag "${oldTagLabel}" to "${newTagLabel}" (${occurrences} occurrence${
occurrences !== 1 ? 's' : ''
} across ${uniqueFiles} file${uniqueFiles !== 1 ? 's' : ''})`
);
} else {
throw new Error('Failed to apply workspace edits');
}
}

View File

@@ -0,0 +1,61 @@
import * as vscode from 'vscode';
import { Foam } from '../../core/model/foam';
import { TagItem } from '../panels/tags-explorer';
export const SEARCH_TAG_COMMAND = {
command: 'foam-vscode.search-tag',
title: 'Foam: Search Tag',
};
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
context.subscriptions.push(
vscode.commands.registerCommand(
SEARCH_TAG_COMMAND.command,
async (tagLabelOrItem?: string | TagItem) => {
let tagLabel: string | undefined;
// Handle both string and TagItem parameters
if (typeof tagLabelOrItem === 'string') {
tagLabel = tagLabelOrItem;
} else if (
tagLabelOrItem &&
typeof tagLabelOrItem === 'object' &&
'tag' in tagLabelOrItem
) {
tagLabel = tagLabelOrItem.tag;
}
if (!tagLabel) {
// If no tag provided, show tag picker
const allTags = Array.from(foam.tags.tags.keys()).sort();
if (allTags.length === 0) {
vscode.window.showInformationMessage('No tags found in workspace.');
return;
}
tagLabel = await vscode.window.showQuickPick(allTags, {
title: 'Select a tag to search',
placeHolder: 'Choose a tag to search for...',
});
if (!tagLabel) {
return; // User cancelled
}
}
// Use VS Code's built-in search with the tag pattern
await vscode.commands.executeCommand('workbench.action.findInFiles', {
query: `#${tagLabel}`,
triggerSearch: true,
matchWholeWord: false,
isCaseSensitive: true,
});
}
)
);
}

View File

@@ -10,6 +10,8 @@ import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
import refactor from './refactor';
import workspaceSymbolProvider from './workspace-symbol-provider';
import tagRenameProvider from './tag-rename-provider';
export const features: FoamFeature[] = [
...Object.values(commands),
@@ -23,4 +25,6 @@ export const features: FoamFeature[] = [
preview,
completionProvider,
tagCompletionProvider,
workspaceSymbolProvider,
tagRenameProvider,
];

View File

@@ -13,6 +13,7 @@ import { FoamGraph } from '../core/model/graph';
import { commandAsURI } from '../utils/commands';
import { CREATE_NOTE_COMMAND } from './commands/create-note';
import { Location } from '../core/model/location';
import { FoamTags } from '../core/model/tags';
describe('Document navigation', () => {
const parser = createMarkdownParser([]);
@@ -33,9 +34,10 @@ describe('Document navigation', () => {
const { uri, content } = await createFile('');
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
@@ -47,9 +49,10 @@ describe('Document navigation', () => {
);
const ws = createTestWorkspace().set(parser.parse(uri, content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const doc = await vscode.workspace.openTextDocument(toVsCodeUri(uri));
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
@@ -62,9 +65,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileA.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(0);
@@ -75,9 +79,10 @@ describe('Document navigation', () => {
const noteA = parser.parse(fileA.uri, fileA.content);
const ws = createTestWorkspace().set(noteA);
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const links = provider.provideDocumentLinks(doc);
expect(links.length).toEqual(1);
@@ -103,9 +108,10 @@ describe('Document navigation', () => {
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
@@ -120,9 +126,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
@@ -147,9 +154,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const definitions = await provider.provideDefinition(
doc,
@@ -170,9 +178,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(0, 22)
@@ -193,9 +202,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const definitions = await provider.provideDefinition(
doc,
new vscode.Position(3, 10)
@@ -223,9 +233,10 @@ describe('Document navigation', () => {
.set(parser.parse(fileC.uri, fileC.content))
.set(parser.parse(fileD.uri, fileD.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileB.uri);
const provider = new NavigationProvider(ws, graph, parser);
const provider = new NavigationProvider(ws, graph, parser, tags);
const refs = await provider.provideReferences(
doc,
@@ -241,6 +252,91 @@ describe('Document navigation', () => {
range: new vscode.Range(0, 23, 0, 23 + 9),
});
});
it('should provide references for tags', async () => {
const fileA = await createFile('This file has #tag1 and #tag2.');
const fileB = await createFile(
'This file also has #tag1 and other content.'
);
const fileC = await createFile('This file has #tag2 and #tag3.');
const ws = createTestWorkspace()
.set(parser.parse(fileA.uri, fileA.content))
.set(parser.parse(fileB.uri, fileB.content))
.set(parser.parse(fileC.uri, fileC.content));
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser, tags);
// Test references for #tag1 (position 15 is within the #tag1 text)
const tag1Refs = await provider.provideReferences(
doc,
new vscode.Position(0, 15)
);
expect(tag1Refs.length).toEqual(2); // #tag1 appears in fileA and fileB
const refUris = tag1Refs.map(ref => ref.uri);
expect(refUris).toContainEqual(toVsCodeUri(fileA.uri));
expect(refUris).toContainEqual(toVsCodeUri(fileB.uri));
});
it('should provide references for tags with different positions', async () => {
const fileA = await createFile(
'Multiple #same-tag mentions #same-tag here.'
);
const ws = createTestWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser, tags);
// Test references for #same-tag (clicking on first occurrence)
const refs = await provider.provideReferences(
doc,
new vscode.Position(0, 10) // Position within first #same-tag
);
expect(refs.length).toEqual(2); // Both occurrences of #same-tag
// Verify both ranges are correct
const sortedRefs = refs.sort(
(a, b) => a.range.start.character - b.range.start.character
);
// First occurrence: "Multiple #same-tag mentions"
expect(sortedRefs[0].range.start.character).toBeLessThan(
sortedRefs[1].range.start.character
);
});
it('should not provide references when position is not on a tag', async () => {
const fileA = await createFile('This file has #tag1 and normal text.');
const ws = createTestWorkspace().set(
parser.parse(fileA.uri, fileA.content)
);
const graph = FoamGraph.fromWorkspace(ws);
const tags = FoamTags.fromWorkspace(ws);
const { doc } = await showInEditor(fileA.uri);
const provider = new NavigationProvider(ws, graph, parser, tags);
// Position on "normal text" (not on a tag or link)
const refs = await provider.provideReferences(
doc,
new vscode.Position(0, 30)
);
expect(refs).toBeUndefined();
});
it.todo('should provide references for placeholders');
});
});

View File

@@ -11,6 +11,7 @@ import { CREATE_NOTE_COMMAND } from './commands/create-note';
import { commandAsURI } from '../utils/commands';
import { Location } from '../core/model/location';
import { getFoamDocSelectors } from '../services/editor';
import { FoamTags } from '../core/model/tags';
export default async function activate(
context: vscode.ExtensionContext,
@@ -21,7 +22,8 @@ export default async function activate(
const navigationProvider = new NavigationProvider(
foam.workspace,
foam.graph,
foam.services.parser
foam.services.parser,
foam.tags
);
context.subscriptions.push(
@@ -61,11 +63,12 @@ export class NavigationProvider
constructor(
private workspace: FoamWorkspace,
private graph: FoamGraph,
private parser: ResourceParser
private parser: ResourceParser,
private tags: FoamTags
) {}
/**
* Provide references for links and placeholders
* Provide references for links, placeholders, and tags
*/
public provideReferences(
document: vscode.TextDocument,
@@ -75,21 +78,50 @@ export class NavigationProvider
fromVsCodeUri(document.uri),
document.getText()
);
// Check if position is on a tag first
const targetTag = resource.tags.find(tag =>
Range.containsPosition(tag.range, position)
);
if (targetTag) {
return this.getTagReferences(targetTag.label);
}
// Check if position is on a link
const targetLink: ResourceLink | undefined = resource.links.find(link =>
Range.containsPosition(link.range, position)
);
if (!targetLink) {
return;
if (targetLink) {
const uri = this.workspace.resolveLink(resource, targetLink);
return this.graph
.getBacklinks(uri)
.map(
connection =>
new vscode.Location(
toVsCodeUri(connection.source),
toVsCodeRange(connection.link.range)
)
);
}
const uri = this.workspace.resolveLink(resource, targetLink);
return;
}
return this.graph.getBacklinks(uri).map(connection => {
return new vscode.Location(
toVsCodeUri(connection.source),
toVsCodeRange(connection.link.range)
/**
* Get all references for a given tag label across the workspace
*/
private getTagReferences(tagLabel: string): vscode.Location[] {
const references: vscode.Location[] = [];
const tagLocations = this.tags.tags.get(tagLabel) ?? [];
for (const tagLocation of tagLocations) {
references.push(
new vscode.Location(
toVsCodeUri(tagLocation.uri),
toVsCodeRange(tagLocation.range)
)
);
});
}
return references;
}
/**

View File

@@ -164,7 +164,7 @@ export class TagsProvider extends FolderTreeProvider<TagTreeItem, string> {
refresh(): void {
this.tags = [...this.foamTags.tags]
.map(([tag, notes]) => ({ tag, notes }))
.map(([tag, resources]) => ({ tag, notes: resources.map(r => r.uri) }))
.sort((a, b) => a.tag.localeCompare(b.tag));
super.refresh();
}
@@ -183,11 +183,13 @@ export class TagsProvider extends FolderTreeProvider<TagTreeItem, string> {
}
private countResourcesInSubtree(node: Folder<string>) {
const nChildren = walk(
node,
tag => this.foamTags.tags.get(tag)?.length ?? 0
).reduce((acc, nResources) => acc + nResources, 0);
return nChildren;
const uniqueUris = new Set<string>();
walk(node, tag => {
const tagLocations = this.foamTags.tags.get(tag) ?? [];
tagLocations.forEach(location => uniqueUris.add(location.uri.toString()));
return 0; // Return value not used when collecting URIs
});
return uniqueUris.size;
}
createFolderTreeItem(
@@ -205,8 +207,9 @@ export class TagsProvider extends FolderTreeProvider<TagTreeItem, string> {
node: Folder<string>
): TagItem {
const nChildren = this.countResourcesInSubtree(node);
const resources = this.foamTags.tags.get(value) ?? [];
return new TagItem(node, nChildren, resources, parent);
const tagLocations = this.foamTags.tags.get(value) ?? [];
const resourceUris = tagLocations.map(location => location.uri);
return new TagItem(node, nChildren, resourceUris, parent);
}
async getChildren(element?: TagItem): Promise<TagTreeItem[]> {
@@ -219,20 +222,20 @@ export class TagsProvider extends FolderTreeProvider<TagTreeItem, string> {
const subtags = await super.getChildren(element);
// Compute the resources children
const resourceTags: ResourceRangeTreeItem[] = (element?.notes ?? [])
.map(uri => this.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
const items = tags.map(t =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
note,
t.range,
'tag'
)
const resourceTags: ResourceRangeTreeItem[] = [];
if (element) {
const tagLocations = this.foamTags.tags.get(element.tag) ?? [];
const resourceTagPromises = tagLocations.map(async tagLocation => {
const note = this.workspace.get(tagLocation.uri);
return ResourceRangeTreeItem.createStandardItem(
this.workspace,
note,
tagLocation.range,
'tag'
);
return [...acc, ...items];
}, []);
});
resourceTags.push(...(await Promise.all(resourceTagPromises)));
}
const resources = (
await groupRangesByResource(this.workspace, resourceTags)
).map(item => {

View File

@@ -2,6 +2,8 @@ import {
WIKILINK_EMBED_REGEX,
WIKILINK_EMBED_REGEX_GROUPS,
retrieveNoteConfig,
parseImageParameters,
generateImageStyles,
} from './wikilink-embed';
import * as config from '../../services/config';
@@ -58,6 +60,324 @@ describe('Wikilink Note Embedding', () => {
});
});
describe('Image Parameter Parsing', () => {
it('should parse wikilinks with image sizing parameters', () => {
// Width only
const match1 = '![[image.png|300]]'.match(WIKILINK_EMBED_REGEX_GROUPS);
expect(match1[0]).toEqual('![[image.png|300]]');
expect(match1[1]).toEqual(undefined); // no modifier
expect(match1[2]).toEqual('image.png');
expect(match1[3]).toEqual('|300');
// Width and height
const match2 = '![[image.png|300x200]]'.match(
WIKILINK_EMBED_REGEX_GROUPS
);
expect(match2[0]).toEqual('![[image.png|300x200]]');
expect(match2[1]).toEqual(undefined);
expect(match2[2]).toEqual('image.png');
expect(match2[3]).toEqual('|300x200');
// Percentage width
const match3 = '![[image.png|50%]]'.match(WIKILINK_EMBED_REGEX_GROUPS);
expect(match3[0]).toEqual('![[image.png|50%]]');
expect(match3[1]).toEqual(undefined);
expect(match3[2]).toEqual('image.png');
expect(match3[3]).toEqual('|50%');
});
it('should parse wikilinks with modifiers and image parameters', () => {
const match = 'content![[image.png|300]]'.match(
WIKILINK_EMBED_REGEX_GROUPS
);
expect(match[0]).toEqual('content![[image.png|300]]');
expect(match[1]).toEqual('content');
expect(match[2]).toEqual('image.png');
expect(match[3]).toEqual('|300');
});
it('should parse wikilinks with multiple parameters', () => {
const match = '![[image.png|300|center]]'.match(
WIKILINK_EMBED_REGEX_GROUPS
);
expect(match[0]).toEqual('![[image.png|300|center]]');
expect(match[1]).toEqual(undefined);
expect(match[2]).toEqual('image.png');
expect(match[3]).toEqual('|300|center');
});
it('should handle wikilinks without parameters (backward compatibility)', () => {
const match = '![[image.png]]'.match(WIKILINK_EMBED_REGEX_GROUPS);
expect(match[0]).toEqual('![[image.png]]');
expect(match[1]).toEqual(undefined);
expect(match[2]).toEqual('image.png');
expect(match[3]).toEqual(undefined);
});
it('should parse complex filenames with parameters', () => {
const match = '![[folder/image-file.png|400px]]'.match(
WIKILINK_EMBED_REGEX_GROUPS
);
expect(match[0]).toEqual('![[folder/image-file.png|400px]]');
expect(match[1]).toEqual(undefined);
expect(match[2]).toEqual('folder/image-file.png');
expect(match[3]).toEqual('|400px');
});
});
describe('parseImageParameters Function', () => {
it('should parse width-only parameters', () => {
const result = parseImageParameters('image.png', '|300');
expect(result).toEqual({
filename: 'image.png',
width: '300',
});
});
it('should parse width x height parameters', () => {
const result = parseImageParameters('image.png', '|300x200');
expect(result).toEqual({
filename: 'image.png',
width: '300',
height: '200',
});
});
it('should parse percentage widths', () => {
const result = parseImageParameters('image.png', '|50%');
expect(result).toEqual({
filename: 'image.png',
width: '50%',
});
});
it('should parse width with units', () => {
const result = parseImageParameters('image.png', '|400px');
expect(result).toEqual({
filename: 'image.png',
width: '400px',
});
});
it('should parse width and alignment', () => {
const result = parseImageParameters('image.png', '|300|center');
expect(result).toEqual({
filename: 'image.png',
width: '300',
align: 'center',
});
});
it('should parse width, alignment, and alt text', () => {
const result = parseImageParameters(
'image.png',
'|300|left|My image description'
);
expect(result).toEqual({
filename: 'image.png',
width: '300',
align: 'left',
alt: 'My image description',
});
});
it('should parse width and alt text (no alignment)', () => {
const result = parseImageParameters(
'image.png',
'|300|My image description'
);
expect(result).toEqual({
filename: 'image.png',
width: '300',
alt: 'My image description',
});
});
it('should handle no parameters', () => {
const result = parseImageParameters('image.png');
expect(result).toEqual({
filename: 'image.png',
});
});
it('should handle empty parameters string', () => {
const result = parseImageParameters('image.png', '');
expect(result).toEqual({
filename: 'image.png',
});
});
it('should handle malformed parameters gracefully', () => {
const result = parseImageParameters('image.png', '|');
expect(result).toEqual({
filename: 'image.png',
});
});
it('should parse complex width x height with units', () => {
const result = parseImageParameters('image.png', '|400px x 300px');
expect(result).toEqual({
filename: 'image.png',
width: '400px',
height: '300px',
});
});
it('should handle right alignment', () => {
const result = parseImageParameters('image.png', '|300|right');
expect(result).toEqual({
filename: 'image.png',
width: '300',
align: 'right',
});
});
it('should handle alt text with pipes', () => {
const result = parseImageParameters(
'image.png',
'|300|center|Alt text with | pipes'
);
expect(result).toEqual({
filename: 'image.png',
width: '300',
align: 'center',
alt: 'Alt text with | pipes',
});
});
});
describe('generateImageStyles Function', () => {
const mockMd = {
normalizeLink: (path: string) => path,
} as any;
it('should generate basic image HTML without parameters', () => {
const params = { filename: 'image.png' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual('<img src="image.png" alt="">');
});
it('should generate image with width only', () => {
const params = { filename: 'image.png', width: '300' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 300px; height: auto" alt="">'
);
});
it('should generate image with width and height', () => {
const params = { filename: 'image.png', width: '300', height: '200' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 300px; height: 200px" alt="">'
);
});
it('should generate image with percentage width', () => {
const params = { filename: 'image.png', width: '50%' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 50%; height: auto" alt="">'
);
});
it('should generate image with width and units preserved', () => {
const params = { filename: 'image.png', width: '400px' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 400px; height: auto" alt="">'
);
});
it('should generate image with center alignment', () => {
const params = {
filename: 'image.png',
width: '300',
align: 'center' as const,
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<div style="text-align: center;"><img src="image.png" style="width: 300px; height: auto" alt=""></div>'
);
});
it('should generate image with left alignment', () => {
const params = {
filename: 'image.png',
width: '300',
align: 'left' as const,
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<div style="text-align: left;"><img src="image.png" style="width: 300px; height: auto" alt=""></div>'
);
});
it('should generate image with right alignment', () => {
const params = {
filename: 'image.png',
width: '300',
align: 'right' as const,
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<div style="text-align: right;"><img src="image.png" style="width: 300px; height: auto" alt=""></div>'
);
});
it('should generate image with alt text', () => {
const params = {
filename: 'image.png',
width: '300',
alt: 'My image description',
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 300px; height: auto" alt="My image description">'
);
});
it('should escape HTML in alt text', () => {
const params = {
filename: 'image.png',
alt: 'Image with <script>alert("xss")</script>',
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" alt="Image with &lt;script&gt;alert(&quot;xss&quot;)&lt;/script&gt;">'
);
});
it('should generate image with width, alignment, and alt text', () => {
const params = {
filename: 'image.png',
width: '300',
align: 'center' as const,
alt: 'Centered image',
};
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<div style="text-align: center;"><img src="image.png" style="width: 300px; height: auto" alt="Centered image"></div>'
);
});
it('should handle em units', () => {
const params = { filename: 'image.png', width: '20em' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 20em; height: auto" alt="">'
);
});
it('should handle decimal values', () => {
const params = { filename: 'image.png', width: '300.5' };
const result = generateImageStyles(params, mockMd);
expect(result).toEqual(
'<img src="image.png" style="width: 300.5px; height: auto" alt="">'
);
});
});
describe('Config Parsing', () => {
it('should use preview.embedNoteType if an explicit modifier is not passed in', () => {
jest

View File

@@ -8,7 +8,7 @@ import { FoamWorkspace } from '../../core/model/workspace';
import { Logger } from '../../core/utils/log';
import { Resource, ResourceParser } from '../../core/model/note';
import { getFoamVsCodeConfig } from '../../services/config';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import { MarkdownLink } from '../../core/services/markdown-link';
import { URI } from '../../core/model/uri';
import { Position } from '../../core/model/position';
@@ -25,7 +25,7 @@ export const WIKILINK_EMBED_REGEX =
// so we capture the entire possible wikilink item (ex. content-card![[note]]) using WIKILINK_EMBED_REGEX and then
// use WIKILINK_EMBED_REGEX_GROUPER to parse it into the modifier(content-card) and the wikilink(note)
export const WIKILINK_EMBED_REGEX_GROUPS =
/((?:\w+)|(?:(?:\w+)-(?:\w+)))?!\[\[([^[\]]+?)\]\]/;
/((?:\w+)|(?:(?:\w+)-(?:\w+)))?!\[\[([^|[\]]+?)(\|[^[\]]+?)?\]\]/;
export const CONFIG_EMBED_NOTE_TYPE = 'preview.embedNoteType';
let refsStack: string[] = [];
@@ -39,9 +39,8 @@ export const markdownItWikilinkEmbed = (
regex: WIKILINK_EMBED_REGEX,
replace: (wikilinkItem: string) => {
try {
const [, noteEmbedModifier, wikilink] = wikilinkItem.match(
WIKILINK_EMBED_REGEX_GROUPS
);
const [, noteEmbedModifier, wikilink, parametersString] =
wikilinkItem.match(WIKILINK_EMBED_REGEX_GROUPS);
if (isVirtualWorkspace()) {
return `
@@ -82,7 +81,8 @@ export const markdownItWikilinkEmbed = (
noteEmbedModifier,
parser,
workspace,
md
md,
parametersString
);
refsStack.pop();
return refsStack.length === 0 ? md.render(content) : content;
@@ -102,7 +102,8 @@ function getNoteContent(
noteEmbedModifier: string | undefined,
parser: ResourceParser,
workspace: FoamWorkspace,
md: markdownit
md: markdownit,
parametersString?: string
): string {
let content = `Embed for [[${includedNote.uri.path}]]`;
let toRender: string;
@@ -137,12 +138,16 @@ Embed for attachments is not supported
</div>`;
toRender = md.render(content);
break;
case 'image':
content = `<div class="embed-container-image">${md.render(
`![](${md.normalizeLink(includedNote.uri.path)})`
)}</div>`;
toRender = md.render(content);
case 'image': {
const imageParams = parseImageParameters(
includedNote.uri.path,
parametersString
);
const imageHtml = generateImageStyles(imageParams, md);
content = `<div class="embed-container-image">${imageHtml}</div>`;
toRender = content;
break;
}
default:
toRender = content;
}
@@ -278,4 +283,119 @@ function inlineFormatter(content: string, md: markdownit): string {
return content;
}
interface ImageParameters {
filename: string;
width?: string;
height?: string;
align?: 'center' | 'left' | 'right';
alt?: string;
}
function parseImageParameters(
wikilink: string,
parametersString?: string
): ImageParameters {
const result: ImageParameters = {
filename: wikilink,
};
if (!parametersString) {
return result;
}
// Remove the leading pipe and split by remaining pipes
const params = parametersString.slice(1).split('|');
if (params.length === 0) {
return result;
}
// First parameter is always size
const sizeParam = params[0]?.trim();
if (sizeParam) {
// Parse size parameter: could be "300", "300x200", "50%", "300px", etc.
// Check for width x height format (but not if it's just a unit like "px")
const dimensionMatch = sizeParam.match(
/^(\d+(?:\.\d+)?(?:px|%|em|rem|vw|vh)?)\s*x\s*(\d+(?:\.\d+)?(?:px|%|em|rem|vw|vh)?)$/i
);
if (dimensionMatch) {
// Width x Height format
result.width = dimensionMatch[1]?.trim();
result.height = dimensionMatch[2]?.trim();
} else {
// Width only
result.width = sizeParam;
}
}
// Second parameter could be alignment
const alignParam = params[1]?.trim().toLowerCase();
if (alignParam && ['center', 'left', 'right'].includes(alignParam)) {
result.align = alignParam as 'center' | 'left' | 'right';
} else if (alignParam) {
// If not alignment, treat as alt text
result.alt = params.slice(1).join('|').trim();
}
// Third parameter onwards is alt text (if second wasn't alt text)
if (result.align && params.length > 2) {
result.alt = params.slice(2).join('|').trim();
}
return result;
}
function generateImageStyles(params: ImageParameters, md: markdownit): string {
const { filename, width, height, align, alt } = params;
// Build CSS styles for the image
const styles: string[] = [];
if (width) {
styles.push(`width: ${addDefaultUnit(width)}`);
// If only width is specified, set height to auto to maintain aspect ratio
if (!height) {
styles.push('height: auto');
}
}
if (height) {
styles.push(`height: ${addDefaultUnit(height)}`);
}
const styleAttr = styles.length > 0 ? ` style="${styles.join('; ')}"` : '';
const altAttr = alt ? ` alt="${escapeHtml(alt)}"` : ' alt=""';
// Generate the image HTML
const imageHtml = `<img src="${md.normalizeLink(
filename
)}"${styleAttr}${altAttr}>`;
// Wrap with alignment if specified
if (align) {
return `<div style="text-align: ${align};">${imageHtml}</div>`;
}
return imageHtml;
}
function addDefaultUnit(value: string): string {
// If no unit is specified and it's a pure number, add 'px'
if (/^\d+(\.\d+)?$/.test(value)) {
return value + 'px';
}
return value;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
export { parseImageParameters, generateImageStyles };
export default markdownItWikilinkEmbed;

View File

@@ -0,0 +1,183 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { TagEdit } from '../core/services/tag-edit';
import {
fromVsCodeUri,
toVsCodeRange,
toVsCodeWorkspaceEdit,
} from '../utils/vsc-utils';
import { Logger } from '../core/utils/log';
import { Position } from '../core/model/position';
/**
* Activates the tag rename provider for native F2 rename support.
*
* This provider enables users to press F2 on any tag in markdown files
* to trigger VS Code's built-in rename functionality, providing a native
* experience for tag renaming that feels like renaming variables in code.
*
* @param context VS Code extension context for registering the provider
* @param foamPromise Promise that resolves to the initialized Foam instance
*/
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
const provider = new TagRenameProvider(foam);
context.subscriptions.push(
vscode.languages.registerRenameProvider('markdown', provider)
);
}
/**
* VS Code rename provider for Foam tags.
*
* This class implements the VS Code RenameProvider interface to enable
* native F2 rename support for tags. It provides seamless integration
* with VS Code's rename system while leveraging Foam's tag infrastructure.
*/
export class TagRenameProvider implements vscode.RenameProvider {
constructor(private foam: Foam) {}
/**
* Prepare a rename operation for VS Code's F2 rename functionality.
*
* This method is called when the user presses F2 or invokes "Rename Symbol"
* from the context menu. It determines if the cursor is positioned on a tag
* and returns the precise range and placeholder text for the rename operation.
*
* @param document The VS Code text document containing the tag
* @param position The cursor position where F2 was pressed
* @param token Cancellation token for the operation
* @returns Range and placeholder for the tag if found, throws error otherwise
* @throws Error if cursor is not positioned on a tag or tag range cannot be found
*/
prepareRename(
document: vscode.TextDocument,
position: vscode.Position,
token: vscode.CancellationToken
): vscode.ProviderResult<
vscode.Range | { range: vscode.Range; placeholder: string }
> {
const fileUri = fromVsCodeUri(document.uri);
const foamPosition = Position.create(position.line, position.character);
const tagLabel = TagEdit.getTagAtPosition(
this.foam.tags,
fileUri,
foamPosition
);
if (!tagLabel) {
// Not on a tag, reject the rename
throw new Error('Cannot rename: cursor is not on a tag');
}
// Find the exact range of this tag occurrence
const tagLocations = this.foam.tags.tags.get(tagLabel) ?? [];
for (const location of tagLocations) {
if (location.uri.toString() !== fileUri.toString()) {
continue;
}
const range = location.range;
const positionInRange =
(position.line === range.start.line &&
position.character >= range.start.character &&
position.line === range.end.line &&
position.character <= range.end.character) ||
(position.line > range.start.line && position.line < range.end.line);
if (positionInRange) {
return {
range: toVsCodeRange(range),
placeholder: tagLabel,
};
}
}
throw new Error('Cannot rename: tag range not found');
}
/**
* Generate workspace edits to perform the tag rename operation.
*
* This method is called after the user enters a new name in the rename dialog.
* It validates the new name and generates all necessary text edits across the
* entire workspace to rename every occurrence of the tag consistently.
*
* @param document The VS Code text document where rename was initiated
* @param position The original cursor position where F2 was pressed
* @param newName The new tag name entered by the user (may include # prefix)
* @param token Cancellation token for the operation
* @returns WorkspaceEdit containing all necessary changes across files
* @throws Error if tag validation fails or rename operation cannot be completed
*/
provideRenameEdits(
document: vscode.TextDocument,
position: vscode.Position,
newName: string,
token: vscode.CancellationToken
): vscode.ProviderResult<vscode.WorkspaceEdit> {
const fileUri = fromVsCodeUri(document.uri);
const foamPosition = Position.create(position.line, position.character);
const oldTagLabel = TagEdit.getTagAtPosition(
this.foam.tags,
fileUri,
foamPosition
);
if (!oldTagLabel) {
throw new Error('Cannot rename: cursor is not on a tag');
}
// Clean the new name (remove # if user included it)
const cleanNewName = newName.startsWith('#')
? newName.substring(1)
: newName;
// Validate the rename
const validation = TagEdit.validateTagRename(
this.foam.tags,
oldTagLabel,
cleanNewName
);
if (!validation.isValid) {
throw new Error(validation.message);
}
// For F2 rename, we don't support merge confirmation dialogs
// Direct users to use the command instead
if (validation.isMerge) {
throw new Error(
`Tag "${cleanNewName}" already exists. Use "Foam: Rename Tag" command to merge tags.`
);
}
try {
// Generate all the edits
const tagEditResult = TagEdit.createRenameTagEdits(
this.foam.tags,
oldTagLabel,
cleanNewName
);
// Convert to VS Code WorkspaceEdit
const workspaceEdit = toVsCodeWorkspaceEdit(
tagEditResult.edits,
this.foam.workspace
);
Logger.info(
`Renaming tag "${oldTagLabel}" to "${cleanNewName}" (${tagEditResult.totalOccurrences} occurrences)`
);
return workspaceEdit;
} catch (error) {
Logger.error('Error during tag rename operation:', error);
throw new Error(`Failed to rename tag: ${error.message}`);
}
}
}

View File

@@ -0,0 +1,136 @@
/* @unit-ready */
import * as vscode from 'vscode';
import { FoamWorkspaceSymbolProvider } from './workspace-symbol-provider';
import { createTestNote, createTestWorkspace } from '../test/test-utils';
describe('FoamWorkspaceSymbolProvider Integration', () => {
let provider: FoamWorkspaceSymbolProvider;
let workspace: any;
beforeEach(async () => {
workspace = createTestWorkspace();
provider = new FoamWorkspaceSymbolProvider(workspace);
});
it('should integrate with VS Code workspace symbol search', async () => {
// Create test notes with aliases
const note1 = createTestNote({
uri: '/test1.md',
aliases: ['first alternative'],
});
const note2 = createTestNote({
uri: '/test2.md',
aliases: ['second alternative'],
});
workspace.set(note1);
workspace.set(note2);
// Test the provider directly (simulating VS Code's call)
const symbols = provider.provideWorkspaceSymbols('alt');
expect(symbols).toHaveLength(2);
const symbolNames = symbols.map(s => s.name);
expect(symbolNames).toContain('first alternative');
expect(symbolNames).toContain('second alternative');
// Verify symbol properties match VS Code expectations
symbols.forEach(symbol => {
expect(symbol).toBeInstanceOf(vscode.SymbolInformation);
expect(symbol.kind).toBe(vscode.SymbolKind.String);
expect(symbol.location).toBeInstanceOf(vscode.Location);
expect(symbol.location.uri).toBeDefined();
expect(symbol.location.range).toBeInstanceOf(vscode.Range);
});
});
it('should handle real-world alias formats from frontmatter', async () => {
// Test with array format aliases
const noteWithArrayAliases = createTestNote({
uri: '/array-aliases.md',
aliases: ['alias one', 'alias two'],
});
// Test with comma-separated format aliases
const noteWithCommaSeparated = createTestNote({
uri: '/comma-aliases.md',
aliases: ['first, second, third'],
});
workspace.set(noteWithArrayAliases);
workspace.set(noteWithCommaSeparated);
// Test searching for different parts
const aliasOneResults = provider.provideWorkspaceSymbols('one');
expect(aliasOneResults).toHaveLength(1);
expect(aliasOneResults[0].name).toBe('alias one');
const commaResults = provider.provideWorkspaceSymbols('first');
expect(commaResults).toHaveLength(1);
expect(commaResults[0].name).toBe('first, second, third');
});
it('should provide location information for navigation', async () => {
const note = createTestNote({
uri: '/location-test.md',
aliases: ['test alias'],
});
workspace.set(note);
const symbols = provider.provideWorkspaceSymbols('test');
expect(symbols).toHaveLength(1);
const symbol = symbols[0];
// The createTestNote function uses default ranges for aliases
expect(symbol.location.range).toBeInstanceOf(vscode.Range);
expect(symbol.containerName).toBe('location-test.md');
});
it('should handle large workspace with many aliases efficiently (so we do not need to cache)', async () => {
// Create many notes with aliases to test performance
for (let i = 0; i < 10000; i++) {
const note = createTestNote({
uri: `/note${i}.md`,
aliases: [`alias number ${i}`, `alternative ${i}`],
});
workspace.set(note);
}
// Performance test - should complete quickly as we have decided not to cache
const start = Date.now();
const symbols = provider.provideWorkspaceSymbols('alternative');
const end = Date.now();
expect(symbols).toHaveLength(10000);
expect(end - start).toBeLessThan(500); // Should complete in under 500ms
});
it('should not interfere with existing markdown symbols', async () => {
// This test verifies that our provider complements VS Code's built-in markdown symbols
// rather than replacing them. We can't directly test VS Code's built-in provider,
// but we can ensure our provider only returns aliases.
const note = createTestNote({
uri: '/mixed-content.md',
title: 'Main Title',
aliases: ['only alias here'],
sections: ['Section Heading'],
});
workspace.set(note);
// Our provider should only return aliases, not sections or titles
const symbols = provider.provideWorkspaceSymbols('');
expect(symbols).toHaveLength(1);
expect(symbols[0].name).toBe('only alias here');
expect(symbols[0].kind).toBe(vscode.SymbolKind.String);
// Should not return sections (those are handled by VS Code's markdown provider)
expect(symbols.find(s => s.name === 'Section Heading')).toBeUndefined();
expect(symbols.find(s => s.name === 'Main Title')).toBeUndefined();
});
});

View File

@@ -0,0 +1,242 @@
import { FoamWorkspaceSymbolProvider } from './workspace-symbol-provider';
import { FoamWorkspace } from '../core/model/workspace';
import { Resource } from '../core/model/note';
import { URI } from '../core/model/uri';
import { Range } from '../core/model/range';
import * as vscode from 'vscode';
describe('FoamWorkspaceSymbolProvider', () => {
describe('matchesQuery', () => {
it('should match empty query', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
const result = provider.provideWorkspaceSymbols('');
expect(result).toEqual([]);
});
it('should match subsequence in alias title', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
expect(provider.matchesQuery('alt', 'alternative title')).toBe(true);
expect(provider.matchesQuery('altit', 'alternative title')).toBe(true);
expect(provider.matchesQuery('title', 'alternative title')).toBe(true);
expect(provider.matchesQuery('tit', 'alternative title')).toBe(true);
});
it('should not match wrong order', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
expect(provider.matchesQuery('title alt', 'alternative title')).toBe(
false
);
expect(provider.matchesQuery('zyx', 'alternative title')).toBe(false);
});
it('should be case insensitive', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
expect(provider.matchesQuery('ALT', 'alternative title')).toBe(true);
expect(provider.matchesQuery('alt', 'ALTERNATIVE TITLE')).toBe(true);
expect(provider.matchesQuery('AlT', 'Alternative Title')).toBe(true);
});
it('should match exact strings', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
expect(
provider.matchesQuery('alternative title', 'alternative title')
).toBe(true);
expect(provider.matchesQuery('', 'alternative title')).toBe(true);
});
});
describe('provideWorkspaceSymbols', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
it('should return empty array when workspace is empty', () => {
const provider = new FoamWorkspaceSymbolProvider(new FoamWorkspace());
const result = provider.provideWorkspaceSymbols('test');
expect(result).toEqual([]);
});
it('should return empty array when no aliases match', () => {
const workspace = new FoamWorkspace();
const provider = new FoamWorkspaceSymbolProvider(workspace);
const resource: Resource = {
uri: URI.file('/test.md'),
type: 'note',
title: 'Test Note',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'different alias',
range: Range.create(0, 0, 0, 10),
},
],
links: [],
definitions: [],
};
workspace.set(resource);
const result = provider.provideWorkspaceSymbols('notfound');
expect(result).toEqual([]);
});
it('should return matching aliases from single resource', () => {
const workspace = new FoamWorkspace();
const provider = new FoamWorkspaceSymbolProvider(workspace);
const aliasRange = Range.create(2, 0, 2, 20);
const resource: Resource = {
uri: URI.file('/test.md'),
type: 'note',
title: 'Test Note',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'alternative title',
range: aliasRange,
},
{
title: 'another name',
range: aliasRange,
},
],
links: [],
definitions: [],
};
workspace.set(resource);
const result = provider.provideWorkspaceSymbols('alt');
expect(result).toHaveLength(1);
expect(result[0].name).toBe('alternative title');
expect(result[0].kind).toBe(vscode.SymbolKind.String);
expect(result[0].containerName).toBe('test.md');
});
it('should return matching aliases from multiple resources', () => {
const workspace = new FoamWorkspace();
const provider = new FoamWorkspaceSymbolProvider(workspace);
const aliasRange = Range.create(2, 0, 2, 20);
const resource1: Resource = {
uri: URI.file('/note1.md'),
type: 'note',
title: 'Note 1',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'alternative one',
range: aliasRange,
},
],
links: [],
definitions: [],
};
const resource2: Resource = {
uri: URI.file('/note2.md'),
type: 'note',
title: 'Note 2',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'alternative two',
range: aliasRange,
},
],
links: [],
definitions: [],
};
workspace.set(resource1);
workspace.set(resource2);
const result = provider.provideWorkspaceSymbols('alt');
expect(result).toHaveLength(2);
expect(result.map(s => s.name)).toContain('alternative one');
expect(result.map(s => s.name)).toContain('alternative two');
expect(result.map(s => s.containerName)).toContain('note1.md');
expect(result.map(s => s.containerName)).toContain('note2.md');
});
it('should return all aliases when query is empty', () => {
const workspace = new FoamWorkspace();
const provider = new FoamWorkspaceSymbolProvider(workspace);
const aliasRange = Range.create(2, 0, 2, 20);
const resource: Resource = {
uri: URI.file('/test.md'),
type: 'note',
title: 'Test Note',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'first alias',
range: aliasRange,
},
{
title: 'second alias',
range: aliasRange,
},
],
links: [],
definitions: [],
};
workspace.set(resource);
const result = provider.provideWorkspaceSymbols('');
expect(result).toHaveLength(2);
expect(result.map(s => s.name)).toContain('first alias');
expect(result.map(s => s.name)).toContain('second alias');
});
it('should create SymbolInformation with correct properties', () => {
const workspace = new FoamWorkspace();
const provider = new FoamWorkspaceSymbolProvider(workspace);
const aliasRange = Range.create(2, 5, 2, 25);
const resource: Resource = {
uri: URI.file('/path/to/note.md'),
type: 'note',
title: 'Test Note',
properties: {},
sections: [],
tags: [],
aliases: [
{
title: 'test alias',
range: aliasRange,
},
],
links: [],
definitions: [],
};
workspace.set(resource);
const result = provider.provideWorkspaceSymbols('test');
expect(result).toHaveLength(1);
const symbol = result[0];
expect(symbol.name).toBe('test alias');
expect(symbol.kind).toBe(vscode.SymbolKind.String);
expect(symbol.containerName).toBe('note.md');
expect(symbol.location.uri.toString()).toContain('/path/to/note.md');
expect(symbol.location.range.start.line).toBe(2);
expect(symbol.location.range.start.character).toBe(5);
expect(symbol.location.range.end.line).toBe(2);
expect(symbol.location.range.end.character).toBe(25);
});
});
});

View File

@@ -0,0 +1,83 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { FoamWorkspace } from '../core/model/workspace';
import { toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
export default async function activate(
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) {
const foam = await foamPromise;
const workspaceSymbolProvider = new FoamWorkspaceSymbolProvider(
foam.workspace
);
context.subscriptions.push(
vscode.languages.registerWorkspaceSymbolProvider(workspaceSymbolProvider)
);
}
/**
* Provides workspace symbols for note aliases.
* Allows users to search for notes by their aliases using "Go To Symbol in Workspace" (Ctrl+T/Cmd+T).
*/
export class FoamWorkspaceSymbolProvider
implements vscode.WorkspaceSymbolProvider
{
constructor(private workspace: FoamWorkspace) {}
/**
* Provide workspace symbols for note aliases.
* Called every time the user types in the symbol search box.
*/
provideWorkspaceSymbols(query: string): vscode.SymbolInformation[] {
return this.workspace
.list()
.flatMap(resource =>
resource.aliases
.filter(
alias => query === '' || this.matchesQuery(query, alias.title)
)
.map(
alias =>
new vscode.SymbolInformation(
alias.title,
vscode.SymbolKind.String,
resource.uri.getBasename(),
new vscode.Location(
toVsCodeUri(resource.uri),
toVsCodeRange(alias.range)
)
)
)
);
}
/**
* Check if a candidate string matches a query using subsequence matching.
* Characters of query must appear in their order in the candidate (case-insensitive).
* This follows VS Code's recommended approach for symbol providers.
*
* Examples:
* - "alt" matches "alternative title"
* - "altit" matches "alternative title"
* - "title alt" does not match "alternative title" (wrong order)
*/
matchesQuery(query: string, candidate: string): boolean {
const queryLower = query.toLowerCase();
const candidateLower = candidate.toLowerCase();
let queryIndex = 0;
for (
let i = 0;
i < candidateLower.length && queryIndex < queryLower.length;
i++
) {
if (candidateLower[i] === queryLower[queryIndex]) {
queryIndex++;
}
}
return queryIndex === queryLower.length;
}
}

View File

@@ -474,4 +474,132 @@ Content without filepath metadata.`,
});
});
});
describe('filepath sanitization', () => {
it('should sanitize invalid characters in filepath from template', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: \${FOAM_TITLE}.md
---
# \${FOAM_TITLE}`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
// Title with many invalid characters (excluding / which is preserved for directories): \#%&{}<>?*$!'":@+`|=
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'Test\\#%&{}<>?*$!\'"Title:@+`|=');
const result = await engine.processTemplate(trigger, template, resolver);
// All invalid characters should become dashes: Test + 14 invalid chars + Title + : + @+`|= (6 more total)
expect(result.filepath.path).toBe('Test--------------Title------.md');
// Content should remain unchanged
expect(result.content).toContain('# Test\\#%&{}<>?*$!\'"Title:@+`|=');
});
it('should not affect FOAM_TITLE when not used in filepath', async () => {
const { engine } = await setupFoamEngine();
// Template with static filepath, FOAM_TITLE only in content
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: notes/static-file.md
---
# \${FOAM_TITLE}
Content with \${FOAM_TITLE} should remain unchanged.`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'Invalid "Characters" <Test>');
const result = await engine.processTemplate(trigger, template, resolver);
// Filepath should remain static (no sanitization needed)
expect(result.filepath.path).toBe('notes/static-file.md');
// Content should use original FOAM_TITLE with invalid characters
expect(result.content).toContain('# Invalid "Characters" <Test>');
expect(result.content).toContain(
'Content with Invalid "Characters" <Test> should remain'
);
});
it('should sanitize complex filepath patterns with multiple variables', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: \${FOAM_DATE_YEAR}/\${FOAM_DATE_MONTH}/\${FOAM_TITLE}.md
---
# \${FOAM_TITLE}
Date and title combination.`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const testDate = new Date('2024-03-15');
const resolver = new Resolver(new Map(), testDate);
resolver.define('FOAM_TITLE', 'Note:With|Invalid*Chars');
resolver.define('FOAM_DATE_YEAR', '2024');
resolver.define('FOAM_DATE_MONTH', '03');
const result = await engine.processTemplate(trigger, template, resolver);
// Entire resolved filepath should be sanitized
expect(result.filepath.path).toBe('2024/03/Note-With-Invalid-Chars.md');
// Content should use original FOAM_TITLE
expect(result.content).toContain('# Note:With|Invalid*Chars');
});
it('should handle filepath with no invalid characters', async () => {
const { engine } = await setupFoamEngine();
const template: Template = {
type: 'markdown',
content: `---
foam_template:
filepath: notes/\${FOAM_TITLE}.md
---
# \${FOAM_TITLE}`,
metadata: new Map(),
};
const trigger = TriggerFactory.createCommandTrigger(
'foam-vscode.create-note'
);
const resolver = new Resolver(new Map(), new Date());
resolver.define('FOAM_TITLE', 'ValidTitle123');
const result = await engine.processTemplate(trigger, template, resolver);
// No sanitization needed - should remain unchanged
expect(result.filepath.path).toBe('notes/ValidTitle123.md');
expect(result.content).toContain('# ValidTitle123');
});
});
});

View File

@@ -12,6 +12,26 @@ import {
import { extractFoamTemplateFrontmatterMetadata } from '../utils/template-frontmatter-parser';
import { URI } from '../core/model/uri';
/**
* Characters that are invalid in file names
* Based on UNALLOWED_CHARS from variable-resolver.ts but excluding forward slash
* which is needed for directory separators in filepaths
*/
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
*/
function sanitizeFilepath(filepath: string): string {
// Escape special regex characters and create character class
const escapedChars = FILEPATH_UNALLOWED_CHARS.replace(/[\\^\-\]]/g, '\\$&');
const regex = new RegExp(`[${escapedChars}]`, 'g');
return filepath.replace(regex, '-');
}
/**
* Unified engine for creating notes from both Markdown and JavaScript templates
*/
@@ -109,10 +129,13 @@ export class NoteCreationEngine {
]);
// Determine filepath - get variables from resolver for default generation
const filepath =
let filepath =
metadata.get('filepath') ??
(await this.generateDefaultFilepath(resolver));
// Sanitize the filepath to remove invalid characters
filepath = sanitizeFilepath(filepath);
return {
filepath: this.roots[0].forPath(filepath),
content: cleanContent,

View File

@@ -63,6 +63,25 @@ describe('variable-resolver, text substitution', () => {
});
describe('variable-resolver, variable resolution', () => {
it('should resolve FOAM_DATE_DAY_ISO correctly for all days', async () => {
// ISO weekday: Monday=1, Sunday=7
const isoResults = [
{ js: 0, iso: '7' }, // Sunday
{ js: 1, iso: '1' }, // Monday
{ js: 2, iso: '2' }, // Tuesday
{ js: 3, iso: '3' }, // Wednesday
{ js: 4, iso: '4' }, // Thursday
{ js: 5, iso: '5' }, // Friday
{ js: 6, iso: '6' }, // Saturday
];
for (const { js, iso } of isoResults) {
// 2025-09-14 is a Sunday, 2025-09-15 is a Monday, etc.
const date = new Date(2025, 8, 14 + js); // September is month 8 (0-based)
const resolver = new Resolver(new Map(), date);
const result = await resolver.resolve(new Variable('FOAM_DATE_DAY_ISO'));
expect(result).toBe(iso);
}
});
it('should do nothing for unknown Foam-specific variables', async () => {
const variables = [new Variable('FOAM_FOO')];
@@ -151,21 +170,24 @@ describe('variable-resolver, variable resolution', () => {
new Variable('FOAM_DATE_MINUTE'),
new Variable('FOAM_DATE_SECOND'),
new Variable('FOAM_DATE_SECONDS_UNIX'),
new Variable('FOAM_DATE_DAY_ISO'),
];
const expected = new Map<string, string>();
const now = new Date();
expected.set(
'FOAM_DATE_YEAR',
new Date().toLocaleString('default', { year: 'numeric' })
now.toLocaleString('default', { year: 'numeric' })
);
expected.set(
'FOAM_DATE_MONTH_NAME',
new Date().toLocaleString('default', { month: 'long' })
now.toLocaleString('default', { month: 'long' })
);
expected.set(
'FOAM_DATE_DATE',
new Date().toLocaleString('default', { day: '2-digit' })
now.toLocaleString('default', { day: '2-digit' })
);
expected.set('FOAM_DATE_DAY_ISO', String(((now.getDay() + 6) % 7) + 1));
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, new Date());
@@ -175,7 +197,7 @@ describe('variable-resolver, variable resolution', () => {
});
it('should resolve FOAM_DATE_* properties with given date', async () => {
const targetDate = new Date(2021, 9, 12, 1, 2, 3);
const targetDate = new Date(2021, 9, 15, 1, 2, 3); // Friday, October 15, 2021
const variables = [
new Variable('FOAM_DATE_YEAR'),
new Variable('FOAM_DATE_YEAR_SHORT'),
@@ -190,6 +212,7 @@ describe('variable-resolver, variable resolution', () => {
new Variable('FOAM_DATE_SECOND'),
new Variable('FOAM_DATE_SECONDS_UNIX'),
new Variable('FOAM_DATE_WEEK'),
new Variable('FOAM_DATE_DAY_ISO'),
];
const expected = new Map<string, string>();
@@ -198,9 +221,9 @@ describe('variable-resolver, variable resolution', () => {
expected.set('FOAM_DATE_MONTH', '10');
expected.set('FOAM_DATE_MONTH_NAME', 'October');
expected.set('FOAM_DATE_MONTH_NAME_SHORT', 'Oct');
expected.set('FOAM_DATE_DATE', '12');
expected.set('FOAM_DATE_DAY_NAME', 'Tuesday');
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Tue');
expected.set('FOAM_DATE_DATE', '15');
expected.set('FOAM_DATE_DAY_NAME', 'Friday');
expected.set('FOAM_DATE_DAY_NAME_SHORT', 'Fri');
expected.set('FOAM_DATE_HOUR', '01');
expected.set('FOAM_DATE_WEEK', '41');
expected.set('FOAM_DATE_MINUTE', '02');
@@ -209,6 +232,7 @@ describe('variable-resolver, variable resolution', () => {
'FOAM_DATE_SECONDS_UNIX',
(targetDate.getTime() / 1000).toString()
);
expected.set('FOAM_DATE_DAY_ISO', '5'); // Friday is 5 in ISO 8601
const givenValues = new Map<string, string>();
const resolver = new Resolver(givenValues, targetDate);

View File

@@ -21,6 +21,7 @@ const knownFoamVariables = new Set([
'FOAM_DATE_MONTH_NAME',
'FOAM_DATE_MONTH_NAME_SHORT',
'FOAM_DATE_DATE',
'FOAM_DATE_DAY_ISO',
'FOAM_DATE_WEEK',
'FOAM_DATE_DAY_NAME',
'FOAM_DATE_DAY_NAME_SHORT',
@@ -190,6 +191,12 @@ export class Resolver implements VariableResolver {
String(this.foamDate.getDate().valueOf()).padStart(2, '0')
);
break;
case 'FOAM_DATE_DAY_ISO':
// ISO 8601 weekday: Monday=1, Sunday=7
value = Promise.resolve(
String(((this.foamDate.getDay() + 6) % 7) + 1)
);
break;
case 'FOAM_DATE_WEEK': {
// https://en.wikipedia.org/wiki/ISO_8601#Week_dates
const date = new Date(this.foamDate);

View File

@@ -29,7 +29,7 @@ const position = Range.create(0, 0, 0, 100);
*/
export const strToUri = URI.file;
export const createTestWorkspace = () => {
export const createTestWorkspace = (workspaceRoots: URI[] = []) => {
const workspace = new FoamWorkspace();
const parser = createMarkdownParser();
const provider = new MarkdownResourceProvider(
@@ -37,7 +37,9 @@ export const createTestWorkspace = () => {
read: _ => Promise.resolve(''),
list: () => Promise.resolve([]),
},
parser
parser,
['.md'],
workspaceRoots
);
workspace.registerProvider(provider);
return workspace;

View File

@@ -170,6 +170,51 @@ export const Uri = {
},
};
// VS Code Location class
export class Location {
constructor(public uri: Uri, public range: Range) {}
}
// VS Code SymbolKind enum
export enum SymbolKind {
File = 0,
Module = 1,
Namespace = 2,
Package = 3,
Class = 4,
Method = 5,
Property = 6,
Field = 7,
Constructor = 8,
Enum = 9,
Interface = 10,
Function = 11,
Variable = 12,
Constant = 13,
String = 14,
Number = 15,
Boolean = 16,
Array = 17,
Object = 18,
Key = 19,
Null = 20,
EnumMember = 21,
Struct = 22,
Event = 23,
Operator = 24,
TypeParameter = 25,
}
// VS Code SymbolInformation class
export class SymbolInformation {
constructor(
public name: string,
public kind: SymbolKind,
public containerName: string,
public location: Location
) {}
}
// Selection extends Range
export class Selection extends Range {
public readonly anchor: Position;
@@ -1543,6 +1588,15 @@ export const languages = {
},
};
},
registerWorkspaceSymbolProvider(provider: any): Disposable {
// Mock workspace symbol provider registration
return {
dispose: () => {
// No-op
},
};
},
};
// Env namespace

View File

@@ -1,7 +1,21 @@
import { Memento, Position, Range, Uri, commands } from 'vscode';
import {
Memento,
Position,
Range,
Uri,
TextEdit,
WorkspaceEdit,
commands,
} from 'vscode';
import { Position as FoamPosition } from '../core/model/position';
import { Range as FoamRange } from '../core/model/range';
import { URI as FoamURI } from '../core/model/uri';
import {
TextEdit as FoamTextEdit,
WorkspaceTextEdit,
} from '../core/services/text-edit';
import { FoamWorkspace } from '../core/model/workspace';
import { Logger } from '../core/utils/log';
export const toVsCodePosition = (p: FoamPosition): Position =>
new Position(p.line, p.character);
@@ -14,6 +28,58 @@ export const toVsCodeUri = (u: FoamURI): Uri => Uri.from(u);
export const fromVsCodeUri = (u: Uri): FoamURI =>
FoamURI.parse(u.toString(), null);
export const toVsCodeTextEdit = (edit: FoamTextEdit): TextEdit =>
new TextEdit(toVsCodeRange(edit.range), edit.newText);
/**
* Convert WorkspaceTextEdit array to VS Code WorkspaceEdit.
*
* @param workspaceTextEdits Array of workspace text edits to convert
* @param workspace Foam workspace for URI resolution
* @returns VS Code WorkspaceEdit ready for application
*/
export const toVsCodeWorkspaceEdit = (
workspaceTextEdits: WorkspaceTextEdit[],
workspace: FoamWorkspace
): WorkspaceEdit => {
const workspaceEdit = new WorkspaceEdit();
// Group edits by URI
const editsByUri = new Map<string, { uri: Uri; edits: TextEdit[] }>();
for (const workspaceTextEdit of workspaceTextEdits) {
const resource = workspace.get(workspaceTextEdit.uri);
if (!resource) {
Logger.warn(
`Could not resolve resource: ${workspaceTextEdit.uri.toString()}`
);
continue;
}
const vscodeUri = toVsCodeUri(resource.uri);
const uriKey = resource.uri.toString();
const existingEntry = editsByUri.get(uriKey) || {
uri: vscodeUri,
edits: [],
};
const vscodeEdit = new TextEdit(
toVsCodeRange(workspaceTextEdit.edit.range),
workspaceTextEdit.edit.newText
);
existingEntry.edits.push(vscodeEdit);
editsByUri.set(uriKey, existingEntry);
}
// Apply grouped edits to workspace
for (const { uri, edits } of editsByUri.values()) {
workspaceEdit.set(uri, edits);
}
return workspaceEdit;
};
/**
* A class that wraps context value, syncs it via setContext, and provides a typed interface to it.
*/

View File

@@ -291,6 +291,19 @@ function augmentGraphInfo(graph) {
});
}
});
const seen = new Set();
graph.links = graph.links.filter(link => {
const sourceId = getLinkNodeId(link.source);
const targetId = getLinkNodeId(link.target);
const key = `${sourceId} -> ${targetId}`;
if (seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
graph.links.forEach(link => {
const a = graph.nodeInfo[link.source];
const b = graph.nodeInfo[link.target];
@@ -299,6 +312,7 @@ function augmentGraphInfo(graph) {
a.links.push(link);
b.links.push(link);
});
return graph;
}

View File

@@ -5,7 +5,7 @@
👀*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 -->
[![All Contributors](https://img.shields.io/badge/all_contributors-128-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-129-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
@@ -372,6 +372,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<tr>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s-jacob-powell"><img src="https://avatars.githubusercontent.com/u/109111499?v=4?s=60" width="60px;" alt="S. Jacob Powell"/><br /><sub><b>S. Jacob Powell</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=s-jacob-powell" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/figdavi"><img src="https://avatars.githubusercontent.com/u/99026991?v=4?s=60" width="60px;" alt="Davi Figueiredo"/><br /><sub><b>Davi Figueiredo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=figdavi" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ChThH"><img src="https://avatars.githubusercontent.com/u/9499483?v=4?s=60" width="60px;" alt="CT Hall"/><br /><sub><b>CT Hall</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ChThH" title="Code">💻</a></td>
</tr>
</tbody>
</table>