mirror of
https://github.com/foambubble/foam.git
synced 2026-01-11 06:58:11 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b5906a1cf | ||
|
|
dc541dea2a | ||
|
|
eb908cb689 | ||
|
|
967ff18d8d | ||
|
|
89298b9652 | ||
|
|
e1694f298b | ||
|
|
61961f0c1d | ||
|
|
2822bfaa9e | ||
|
|
9af4e814ac | ||
|
|
f8f2ecbec8 | ||
|
|
6d4db373bf | ||
|
|
9149546445 | ||
|
|
4893d55ed3 | ||
|
|
53caa94013 | ||
|
|
eda46ac006 | ||
|
|
37837a314d | ||
|
|
fc084c736e | ||
|
|
ca5229f557 | ||
|
|
f96282828c | ||
|
|
c863586cd0 | ||
|
|
a6c0cc603f |
@@ -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
1
.gitignore
vendored
@@ -11,3 +11,4 @@ docs/_site
|
||||
docs/.sass-cache
|
||||
docs/.jekyll-metadata
|
||||
.test-workspace
|
||||
.agent/tasks
|
||||
|
||||
21
CLAUDE.md
21
CLAUDE.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.27.6"
|
||||
"version": "0.28.1"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
``
|
||||
).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(),
|
||||
``
|
||||
).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(),
|
||||
``
|
||||
).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(),
|
||||
``
|
||||
).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(), ``)
|
||||
.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(),
|
||||
``
|
||||
).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(),
|
||||
``
|
||||
).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(
|
||||
|
||||
@@ -6,7 +6,7 @@ export abstract class MarkdownLink {
|
||||
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
|
||||
);
|
||||
private static directLinkRegex = new RegExp(
|
||||
/\[(.*)\]\(<?([^#>]*)?#?([^\]>]+)?>?\)/
|
||||
/\[(.*)\]\(<?([^#>]*?)(?:#([^>\s"'()]*))?(?:\s+(?:"[^"]*"|'[^']*'))?>?\)/
|
||||
);
|
||||
|
||||
public static analyzeLink(link: ResourceLink) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
611
packages/foam-vscode/src/core/services/tag-edit.test.ts
Normal file
611
packages/foam-vscode/src/core/services/tag-edit.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
284
packages/foam-vscode/src/core/services/tag-edit.ts
Normal file
284
packages/foam-vscode/src/core/services/tag-edit.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
324
packages/foam-vscode/src/features/commands/rename-tag.ts
Normal file
324
packages/foam-vscode/src/features/commands/rename-tag.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
61
packages/foam-vscode/src/features/commands/search-tag.ts
Normal file
61
packages/foam-vscode/src/features/commands/search-tag.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 <script>alert("xss")</script>">'
|
||||
);
|
||||
});
|
||||
|
||||
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
|
||||
|
||||
@@ -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(
|
||||
`})`
|
||||
)}</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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
export { parseImageParameters, generateImageStyles };
|
||||
export default markdownItWikilinkEmbed;
|
||||
|
||||
183
packages/foam-vscode/src/features/tag-rename-provider.ts
Normal file
183
packages/foam-vscode/src/features/tag-rename-provider.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 -->
|
||||
[](#contributors-)
|
||||
[](#contributors-)
|
||||
<!-- ALL-CONTRIBUTORS-BADGE:END -->
|
||||
|
||||
[](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>
|
||||
|
||||
Reference in New Issue
Block a user