mirror of
https://github.com/foambubble/foam.git
synced 2026-01-10 14:38:13 -05:00
Compare commits
98 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf70c97fd7 | ||
|
|
5d8b9756a9 | ||
|
|
f63b7b2c9b | ||
|
|
875fde8a45 | ||
|
|
5ef292382e | ||
|
|
0fa0176e1b | ||
|
|
d312eca3d1 | ||
|
|
bff39be923 | ||
|
|
52c16b15df | ||
|
|
f2a5c6c45a | ||
|
|
6c354a13d8 | ||
|
|
8826a2b358 | ||
|
|
761eeb7336 | ||
|
|
6d9a8305ce | ||
|
|
4ee065ff9a | ||
|
|
df3cd90ce7 | ||
|
|
0af6c4de15 | ||
|
|
3d49146087 | ||
|
|
8cb2246278 | ||
|
|
72890d33cb | ||
|
|
70b11a921a | ||
|
|
f457b14ec0 | ||
|
|
bbb4801486 | ||
|
|
80b1537324 | ||
|
|
007315c3a1 | ||
|
|
ec1750d5a6 | ||
|
|
5480c65a48 | ||
|
|
7afa286ea5 | ||
|
|
4a7c2d9de2 | ||
|
|
1f6b2abce2 | ||
|
|
5f017ee4ea | ||
|
|
61032668be | ||
|
|
9192cefc7c | ||
|
|
2f966276b5 | ||
|
|
145970a6cb | ||
|
|
54a6ffdf01 | ||
|
|
40740db416 | ||
|
|
145653ec85 | ||
|
|
503b486179 | ||
|
|
a36d39acf8 | ||
|
|
fb92790a0a | ||
|
|
dcb951004a | ||
|
|
3b5906a1cf | ||
|
|
dc541dea2a | ||
|
|
eb908cb689 | ||
|
|
967ff18d8d | ||
|
|
89298b9652 | ||
|
|
e1694f298b | ||
|
|
61961f0c1d | ||
|
|
2822bfaa9e | ||
|
|
9af4e814ac | ||
|
|
f8f2ecbec8 | ||
|
|
6d4db373bf | ||
|
|
9149546445 | ||
|
|
4893d55ed3 | ||
|
|
53caa94013 | ||
|
|
eda46ac006 | ||
|
|
37837a314d | ||
|
|
fc084c736e | ||
|
|
ca5229f557 | ||
|
|
f96282828c | ||
|
|
c863586cd0 | ||
|
|
a6c0cc603f | ||
|
|
8ed2d17793 | ||
|
|
08aae069fe | ||
|
|
09c1426926 | ||
|
|
4694cfae8d | ||
|
|
9ca36c9d81 | ||
|
|
65367b53b4 | ||
|
|
43a2984047 | ||
|
|
b0800fd30e | ||
|
|
5cbc722929 | ||
|
|
f57b8ec9b6 | ||
|
|
0c7b1458f5 | ||
|
|
8c31b563cc | ||
|
|
ca7bfdff1d | ||
|
|
1fe786c5c2 | ||
|
|
649bd6440a | ||
|
|
7a562aa0aa | ||
|
|
0bab17c130 | ||
|
|
8121223e30 | ||
|
|
793664ac59 | ||
|
|
4c5430d2b1 | ||
|
|
ebef851f5a | ||
|
|
253ee94b1c | ||
|
|
9ffd465a32 | ||
|
|
ff3dacdbbf | ||
|
|
0a6350464b | ||
|
|
fe0228bdcc | ||
|
|
471260bdd3 | ||
|
|
a22f1b46dc | ||
|
|
318641ae04 | ||
|
|
12a4fd98c3 | ||
|
|
a93360eb1b | ||
|
|
0938de2694 | ||
|
|
a120f368c3 | ||
|
|
c028689012 | ||
|
|
27665154db |
@@ -1175,6 +1175,24 @@
|
||||
"contributions": [
|
||||
"doc"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "ChThH",
|
||||
"name": "CT Hall",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/9499483?v=4",
|
||||
"profile": "https://github.com/ChThH",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
},
|
||||
{
|
||||
"login": "meestahp",
|
||||
"name": "meestahp",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/177708514?v=4",
|
||||
"profile": "https://github.com/meestahp",
|
||||
"contributions": [
|
||||
"code"
|
||||
]
|
||||
}
|
||||
],
|
||||
"contributorsPerLine": 7,
|
||||
|
||||
59
.claude/commands/prepare-pr.md
Normal file
59
.claude/commands/prepare-pr.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# Prepare PR Command
|
||||
|
||||
Analyze the current branch changes and generate:
|
||||
|
||||
- a PR title
|
||||
- a PR description
|
||||
- considerations for the developer before pushing the PR
|
||||
|
||||
Output the title and description ready to paste into GitHub.
|
||||
|
||||
## PR TITLE
|
||||
|
||||
Use format: `type(scope): description`
|
||||
|
||||
- type: feat/fix/refactor/perf/docs/chore
|
||||
- Keep under 72 characters
|
||||
- Be specific but brief
|
||||
|
||||
## PR DESCRIPTION
|
||||
|
||||
It should have these sections (use a paragraph per section, no need to title them).
|
||||
CONSTRAINTS:
|
||||
|
||||
- 100-200 words total
|
||||
- No file names or "updated X file" statements
|
||||
- Active voice
|
||||
- No filler or pleasantries
|
||||
- Focus on WHAT and WHY, not HOW
|
||||
|
||||
### What Changed
|
||||
|
||||
List 2-4 changes grouped by DOMAIN, not files. Focus on:
|
||||
|
||||
- User-facing changes
|
||||
- Architectural shifts
|
||||
- API changes
|
||||
Skip trivial updates (formatting, minor refactors).
|
||||
|
||||
### Why
|
||||
|
||||
One sentence explaining motivation (skip if obvious from title).
|
||||
|
||||
### Critical Notes
|
||||
|
||||
ONLY include if relevant:
|
||||
|
||||
- Breaking changes
|
||||
- Performance impact
|
||||
- Security implications
|
||||
- New dependencies
|
||||
- Required config/env changes
|
||||
- Database migrations
|
||||
|
||||
If no critical notes exist, omit this section.
|
||||
|
||||
## Considerations
|
||||
|
||||
Run the `yarn lint` command and report any failures.
|
||||
Also analize the changeset, and act as a PR reviewer to provide comments about the changes.
|
||||
61
.claude/commands/research-issue.md
Normal file
61
.claude/commands/research-issue.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Research Issue Command
|
||||
|
||||
Research a GitHub issue by analyzing the issue details and codebase to generate a comprehensive task analysis file.
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
/research-issue <issue-number>
|
||||
```
|
||||
|
||||
## Parameters
|
||||
|
||||
- `issue-number` (required): The GitHub issue number to research
|
||||
|
||||
## Description
|
||||
|
||||
This command performs comprehensive research on a GitHub issue by:
|
||||
|
||||
1. **Fetching Issue Details**: Uses `gh issue view` to get issue title, description, labels, comments, and related information
|
||||
2. **Codebase Analysis**: Searches the codebase for relevant files, patterns, and components mentioned in the issue
|
||||
3. **Root Cause Analysis**: Identifies possible technical causes based on the issue description and codebase findings
|
||||
4. **Solution Planning**: Proposes two solution approaches ranked by preference
|
||||
5. **Documentation**: Creates a structured task file in `.agent/tasks/<issue-id>-<sanitized-title>.md`
|
||||
|
||||
If there is already a `.agent/tasks/<issue-id>-<sanitized-title>.md` file, use it for context and update it accordingly.
|
||||
If at any time during these steps you need clarifying information from me, please ask.
|
||||
|
||||
## Output Format
|
||||
|
||||
Creates a markdown file with:
|
||||
|
||||
- Issue summary and key details
|
||||
- Research findings from codebase analysis
|
||||
- Identified possible root causes
|
||||
- Two ranked solution approaches with pros/cons
|
||||
- Technical considerations and dependencies
|
||||
|
||||
## Examples
|
||||
|
||||
```
|
||||
/research-issue 1234
|
||||
/research-issue 567
|
||||
```
|
||||
|
||||
## Implementation
|
||||
|
||||
The command will:
|
||||
|
||||
1. Validate the issue number and check if it exists
|
||||
2. Fetch issue details using GitHub CLI
|
||||
3. Search codebase for relevant patterns, files, and components
|
||||
4. Analyze findings to identify root causes
|
||||
5. Generate structured markdown file with research results
|
||||
6. Save to `.agent/tasks/` directory with standardized naming
|
||||
|
||||
## Error Handling
|
||||
|
||||
- Invalid issue numbers
|
||||
- GitHub CLI authentication issues
|
||||
- Network connectivity problems
|
||||
- File system write permissions
|
||||
2
.github/workflows/update-docs.yml
vendored
2
.github/workflows/update-docs.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
|
||||
# Strip autogenerated wikileaks references because
|
||||
# they are not an appropriate default user experience.
|
||||
(cd foam-template/docs; sed -i '/\[\/\/begin\]/,/\[\/\/end\]/d' $(find . -type f -name \*.md))
|
||||
(cd foam-template/docs; find . -type f -name '*.md' -exec sed -i '/\[\/\/begin\]/,/\[\/\/end\]/d' {} +)
|
||||
|
||||
# Set the commit message format
|
||||
echo "message=Docs sync @ $(cd foam; git log --pretty='format:%h %s')" >> $GITHUB_OUTPUT
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -11,3 +11,4 @@ docs/_site
|
||||
docs/.sass-cache
|
||||
docs/.jekyll-metadata
|
||||
.test-workspace
|
||||
.agent/tasks
|
||||
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -24,9 +24,8 @@
|
||||
"editor.formatOnSave": true,
|
||||
"editor.formatOnSaveMode": "file",
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"jest.autoRun": "off",
|
||||
"jest.rootPath": "packages/foam-vscode",
|
||||
"jest.jestCommandLine": "yarn test:unit-with-specs",
|
||||
"jest.jestCommandLine": "yarn test:unit",
|
||||
"gitdoc.enabled": false,
|
||||
"search.mode": "reuseEditor",
|
||||
"[typescript]": {
|
||||
|
||||
57
CLAUDE.md
57
CLAUDE.md
@@ -2,8 +2,18 @@
|
||||
|
||||
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.
|
||||
|
||||
## Quick Commands
|
||||
|
||||
All the following commands are to be executed from the `packages/foam-vscode` directory
|
||||
|
||||
### Development
|
||||
|
||||
- `yarn install` - Install dependencies
|
||||
@@ -15,15 +25,22 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
### Testing
|
||||
|
||||
- `yarn test` - Run all tests (unit + integration)
|
||||
- `yarn test:unit-with-specs` - Run only unit tests (\*.test.ts files and the .spec.ts files marked a vscode-mock friendly)
|
||||
- `yarn test:unit` - Run unit tests (\*.test.ts files and the .spec.ts files marked a vscode-mock friendly)
|
||||
- `yarn test:e2e` - Run only integration tests (\*.spec.ts files)
|
||||
- `yarn lint` - Run linting
|
||||
- `yarn test-reset-workspace` to clean test workspace
|
||||
|
||||
Unit tests run in Node.js environment using Jest
|
||||
Integration tests require VS Code extension host
|
||||
When running tests, do not provide additional parameters, they are ignored by the custom runner script. You cannot run just a test, you have to run the whole suite.
|
||||
|
||||
While in development we mostly want to use `yarn test:unit-with-specs`.
|
||||
Unit tests are named `*.test.ts` and integration tests are `*.spec.ts`. These test files live alongside the code in the `src` directory. An integration test is one that has a direct or indirect dependency on `vscode` module.
|
||||
There is a mock `vscode` module that can be used to run most integration tests without starting VS Code. Tests that can use this mock are start with the line `/* @unit-ready */`.
|
||||
|
||||
- If you are interested in a test inside a `*.test.ts` file, run `yarn test:unit` or inside a `*.spec.ts` file that starts with `/* @unit-ready */` run `yarn test:unit`
|
||||
- If you are interested in a test inside a `*.spec.ts` file that does not include `/* @unit-ready */` run `yarn test`
|
||||
|
||||
While in development we mostly want to use `yarn test:unit`.
|
||||
When multiple tests are failing, look at all of them, but only focus on fixing the first one. Once that is fixed, run the test suite again and repeat the process.
|
||||
|
||||
When writing tests keep mocking to a bare minimum. Code should be written in a way that is easily testable and if I/O is necessary, it should be done in appropriate temporary directories.
|
||||
@@ -33,6 +50,8 @@ Use the utility functions from `test-utils.ts` and `test-utils-vscode.ts` and `t
|
||||
|
||||
To improve readability of the tests, set up the test and tear it down within the test case (as opposed to use other functions like `beforeEach` unless it's much better to do it that way)
|
||||
|
||||
Never fix a test by adjusting the expectation if the expectation is correct, test must be fixed by addressing the issue with the code.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
This is a monorepo using Yarn workspaces with the main VS Code extension in `packages/foam-vscode/`.
|
||||
@@ -45,6 +64,10 @@ This is a monorepo using Yarn workspaces with the main VS Code extension in `pac
|
||||
- `packages/foam-vscode/src/test/` - Test utilities and mocks
|
||||
- `docs/` - Documentation and user guides
|
||||
|
||||
### File Naming Patterns
|
||||
|
||||
Test files follow `*.test.ts` for unit tests and `*.spec.ts` for integration tests, living alongside the code in `src`. An integration test is one that has a direct or indirect dependency on `vscode` package.
|
||||
|
||||
### Important Constraint
|
||||
|
||||
Code in `packages/foam-vscode/src/core/` MUST NOT depend on the `vscode` library or any files outside the core directory. This maintains platform independence.
|
||||
@@ -99,9 +122,21 @@ This allows features to:
|
||||
|
||||
## Development Workflow
|
||||
|
||||
We build production code together. I handle implementation details while you guide architecture and catch complexity early.
|
||||
When working on an issue, check if a `.agent/tasks/<issue-id>-<sanitized-title>.md` exists. If not, suggest whether we should start by doing a research on it (using the `/research-issue <issue-id>`) command.
|
||||
Whenever we work together on a task, feel free to challenge my assumptions and ideas and be critical if useful.
|
||||
|
||||
## Core Workflow: Research → Plan → Implement → Validate
|
||||
|
||||
**Start every feature with:** "Let me research the codebase and create a plan before implementing."
|
||||
|
||||
1. **Research** - Understand existing patterns and architecture
|
||||
2. **Plan** - Propose approach and verify with you
|
||||
3. **Implement** - Build with tests and error handling
|
||||
4. **Validate** - ALWAYS run formatters, linters, and tests after implementation
|
||||
|
||||
- Whenever working on a feature or issue, let's always come up with a plan first, then save it to a file called `/.agent/current-plan.md`, before getting started with code changes. Update this file as the work progresses.
|
||||
- Let's use pure functions where possible to improve readability and testing.
|
||||
- After saving a file, always run `prettier` on it to adjust its formatting.
|
||||
|
||||
### Adding New Features
|
||||
|
||||
@@ -147,6 +182,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.
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Achieving Greater Privacy and Security
|
||||
|
||||
Foam, at its heart and committed to in its [Principles](https://foambubble.github.io/foam/principles), allows the user to control their content in a flexible and non-prescriptive manner. This extends to user preferences, or requirements depending on application and context, around both privacy and security. One way that these use cases can be met is through the use of open-source and not-for-profit mechanisms in the user's workflow to provide a functional equivalence.
|
||||
|
||||
Here are a few suggestions on increasing privacy and security when using Foam.
|
||||
## VS Codium: The Open Source build of VS Code
|
||||
|
||||
Foam is built upon VS Code, itself a Microsoft product built on top of an open source project.
|
||||
|
||||
As can be found [here](https://github.com/Microsoft/vscode/issues/60#issuecomment-161792005) the **VS Code product itself is not fully open source**. This means that its inner workings are not fully transparent, facilitating the collection and distribution of your data, as specified in its [Privacy Statement](https://devblogs.microsoft.com/visualstudio/privacy/).
|
||||
|
||||
If you prefer a fully open source editor based on the same core of VS Code (and for most intents and purposes equivalent to it), you can try [VSCodium](https://github.com/VSCodium).
|
||||
In its own introduction it is described as, "Binary releases of VS Code without MS branding/telemetry/licensing". Installation packages are easily available across Windows, Unix and Linux (or you can build it from source!).
|
||||
Access to the VS Code marketplace of add-ons remains in place, including the Foam extension.
|
||||
|
||||
The change you will notice in using VS Code versus VS Codium - simply speaking, none. It is, in just about every way you will think of, the same IDE, just without the Microsoft proprietary licence and telemetry. Your Foam experience will remain as smooth and productive as before the change.
|
||||
|
||||
## Version Control and Replication
|
||||
|
||||
In Foam's [Getting Started](https://foambubble.github.io/foam/#getting-started) section, the set up describes how to set up your notes with a GitHub repository in using the template provided. Doing so provides the user with the ability to see commits made and therefore versions of their notes, allows the user to work across devices or collaborate effectively with other users, and makes publishing to GitHub pages easy.
|
||||
It's important at the same time to point out the closed-source nature of GitHub, being owned by Microsoft.
|
||||
|
||||
One alternative approach could be to use [GitLab](https://gitlab.com/), an open source alternative to GitHub. Whilst it improves on the aspect of transparency, it does also collect usage details and sends your content across the internet.
|
||||
And of course data is still stored in clear in the cloud, making it susceptible to hacks of the service.
|
||||
|
||||
A more private approach would manage replication between devices and users with a serverless mechanism like [Syncthing](https://syncthing.net). Its continuous synchronisation means that changes in files are seen almost instantly and offers the choice of using only local network connections or securely using public relays when a local network connection is unavailable. This means that having two connected devices online will have them synchronised, but it is worth noting that the continuous synchronisation could result in corruption if two users worked on the same file simultaneously and it doesn't offer the same kind of version control that git does (though versioning support can be found and is described [here](https://docs.syncthing.net/users/versioning.html)). It is also not advisable to attempt to use a continuous synchronisation tool to sync local git repositories as the risk of corruption on the git files is high (see [here](https://forum.syncthing.net/t/can-syncthing-reliably-sync-local-git-repos-not-github/8404/18)).
|
||||
|
||||
If you need the version control and collaboration, but do not want to compromise on your privacy, the best course of action is to host the open source GitLab server software yourself. The steps (well described [here](https://www.techrepublic.com/article/how-to-set-up-a-gitlab-server-and-host-your-own-git-repositories/)) are not especially complex by any means and can be used exclusively on the local network, if required, offering a rich experience of "built-in version control, issue tracking, code review, CI/CD, and more", according to its website, [GitLab / GitLab Community Edition · GitLab](https://gitlab.com/rluna-gitlab/gitlab-ce).
|
||||
|
||||
1
docs/CNAME
Normal file
1
docs/CNAME
Normal file
@@ -0,0 +1 @@
|
||||
foamnotes.com
|
||||
@@ -1,17 +0,0 @@
|
||||
# Big Vision
|
||||
|
||||
[[todo]]
|
||||
|
||||
- What methodologies do we want to support?
|
||||
- Zettelkasten?
|
||||
- GTD? (Get Things Done)
|
||||
- Digital gardening?
|
||||
- Blogging/publishing
|
||||
- Others?
|
||||
- Be an educational tool as much as a tool to implement these methodologies
|
||||
- What use cases are we working towards?
|
||||
-[[todo]] User round table
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[todo]: dev/todo.md "Todo"
|
||||
[//end]: # "Autogenerated link references"
|
||||
@@ -1,6 +1,6 @@
|
||||
# Foam File Format
|
||||
|
||||
This file is an example of a valid Foam file. Essentially it's just a markdown file with a bit of additional support for MediaWiki-style `[[wikilinks]]`.
|
||||
This file is an example of a valid Foam file. Essentially it's just a markdown file with a bit of additional support for MediaWiki-style `[[wikilinks]]` and note embeds.
|
||||
|
||||
Here are a few specific constraints, mainly because our tooling is a bit fragmented. Most of these should be eventually lifted, and our requirement should just be "Markdown with `[[wikilinks]]`:
|
||||
|
||||
@@ -10,7 +10,6 @@ Here are a few specific constraints, mainly because our tooling is a bit fragmen
|
||||
- This is a temporary limitation and will be lifted in future versions.
|
||||
- At least `.mdx` will be supported, but ideally we'll support any file that you can map to `Markdown` language mode in VS Code
|
||||
- **In addition to normal Markdown Links syntax you can use `[[MediaWiki]]` links.** See [[wikilinks]] for more details.
|
||||
- **You can embed other notes using `![[note]]` syntax.** This supports various modifiers like `content![[note]]` or `full-card![[note]]` to control how content is displayed.
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[wikilinks]: ../user/features/wikilinks.md "Wikilinks"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[wikilinks]: ../user/features/wikilinks.md 'Wikilinks'
|
||||
|
||||
@@ -49,8 +49,8 @@ This dual-environment capability allows us to:
|
||||
|
||||
### Available Commands
|
||||
|
||||
- **`yarn test:unit`**: Runs only `.test.ts` files (no VS Code dependencies)
|
||||
- **`yarn test:unit-with-specs`**: Runs `.test.ts` + `@unit-ready` marked `.spec.ts` files using mocks
|
||||
- **`yarn test:unit`**: Runs `.test.ts` files (no VS Code dependencies) + `@unit-ready` marked `.spec.ts` files using mocks
|
||||
- **`yarn test:unit-without-specs`**: Runs only `.test.ts` files
|
||||
- **`yarn test:e2e`**: Runs all `.spec.ts` files in full VS Code extension host
|
||||
- **`yarn test`**: Runs both unit and e2e test suites sequentially
|
||||
|
||||
|
||||
128
docs/index.md
128
docs/index.md
@@ -1,10 +1,25 @@
|
||||
# Foam
|
||||
# What is Foam?
|
||||
|
||||
**Foam** is a personal knowledge management and sharing system inspired by [Roam Research](https://roamresearch.com/), built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/).
|
||||
Foam is a personal knowledge management system built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/). It helps you organize research, create discoverable notes, and publish your knowledge.
|
||||
|
||||
You can use **Foam** for organising your research, keeping re-discoverable notes, writing long-form content and, optionally, publishing it to the web.
|
||||
## Key Features
|
||||
|
||||
**Foam** is free, open source, and extremely extensible to suit your personal workflow. You own the information you create with Foam, and you're free to share it, and collaborate on it with anyone you want.
|
||||
- **Wikilinks** - Connect thoughts with `[[double bracket]]` syntax
|
||||
- **Embeds** - Include content from other notes with `![[note]]` syntax
|
||||
- **Backlinks** - Automatically discover connections between notes
|
||||
- **Graph visualization** - See your knowledge network visually
|
||||
- **Daily notes** - Capture timestamped thoughts
|
||||
- **Templates** - Standardize note creation
|
||||
- **Tags** - Organize and filter content
|
||||
|
||||
## Why Choose Foam?
|
||||
|
||||
- **Free and open source** - No subscriptions or vendor lock-in
|
||||
- **Own your data** - Notes stored as standard Markdown files
|
||||
- **VS Code integration** - Leverage powerful editing and extensions
|
||||
- **Git-based** - Version control and collaboration built-in
|
||||
|
||||
Foam is like a bathtub: _What you get out of it depends on what you put into it._
|
||||
|
||||
<p class="announcement">
|
||||
<b>New!</b> Join <a href="https://foambubble.github.io/join-discord/w" target="_blank">Foam community Discord</a> for users and contributors!
|
||||
@@ -17,88 +32,79 @@ You can use **Foam** for organising your research, keeping re-discoverable notes
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Foam](#foam)
|
||||
- [What is Foam?](#what-is-foam)
|
||||
- [Key Features](#key-features)
|
||||
- [Why Choose Foam?](#why-choose-foam)
|
||||
- [Table of Contents](#table-of-contents)
|
||||
- [How do I use Foam?](#how-do-i-use-foam)
|
||||
- [What's in a Foam?](#whats-in-a-foam)
|
||||
- [Getting started](#getting-started)
|
||||
- [Features](#features)
|
||||
- [Call To Adventure](#call-to-adventure)
|
||||
- [Contributing](#contributing)
|
||||
- [Thanks and attribution](#thanks-and-attribution)
|
||||
- [License](#license)
|
||||
|
||||
## How do I use Foam?
|
||||
|
||||
**Foam** is a tool that supports creating relationships between thoughts and information to help you think better.
|
||||
Foam helps you create relationships between thoughts and information through:
|
||||
|
||||
Whether you want to build a [Second Brain](https://www.buildingasecondbrain.com/) or a [Zettelkasten](https://zettelkasten.de/posts/overview/), write a book, or just get better at long-term learning, **Foam** can help you organise your thoughts if you follow these simple rules:
|
||||
1. **Atomic notes** - Write focused markdown documents on single topics
|
||||
2. **Wikilinks** - Connect ideas with `[[double bracket]]` syntax
|
||||
3. **Backlinks** - Discover unexpected connections between notes
|
||||
4. **Graph visualization** - See your knowledge network visually
|
||||
|
||||
1. Create a single **Foam** workspace for all your knowledge and research following the [Getting started](#getting-started) guide.
|
||||
2. Write your thoughts in markdown documents (I like to call them **Bubbles**, but that might be more than a little twee). These documents should be atomic: Put things that belong together into a single document, and limit its content to that single topic. ([source](https://zettelkasten.de/posts/overview/#principles))
|
||||
3. Use Foam's shortcuts and autocompletions to link your thoughts together with `[[wikilinks]]`, and navigate between them to explore your knowledge graph.
|
||||
4. Get an overview of your **Foam** workspace using a [[graph-visualization]] (⚠️ WIP), and discover relationships between your thoughts with the use of [[backlinking]].
|
||||
|
||||
Foam is a like a bathtub: _What you get out of it depends on what you put into it._
|
||||
Success with Foam depends on consistent note-taking and linking habits.
|
||||
|
||||
## What's in a Foam?
|
||||
|
||||
Like the soapy suds it's named after, **Foam** is mostly air.
|
||||
Foam combines existing tools:
|
||||
|
||||
1. The editing experience of **Foam** is powered by VS Code, enhanced by workspace settings that glue together [[recommended-extensions]] and preferences optimised for writing and navigating information.
|
||||
2. To back up, collaborate on and share your content between devices, Foam pairs well with [GitHub](http://github.com/).
|
||||
3. To publish your content, you can set it up to publish to [GitHub Pages](https://pages.github.com/), or to any website hosting platform like [Netlify](http://netlify.com/) or [Vercel](https://vercel.com).
|
||||
1. **VS Code** - Enhanced with [[recommended-extensions]] optimized for knowledge management
|
||||
2. **GitHub** - Version control, backup, and collaboration
|
||||
3. **Static site generators** - Publish to GitHub Pages, Netlify, or Vercel
|
||||
|
||||
> **Fun fact**: This documentation was researched, written and published using **Foam**.
|
||||
> This documentation was created using Foam.
|
||||
|
||||
## Getting started
|
||||
|
||||
> ⚠️ Foam is still in preview. Expect the experience to be a little rough.
|
||||
**Requirements:** GitHub account and Visual Studio Code
|
||||
|
||||
These instructions assume you have a GitHub account, and you have Visual Studio Code installed.
|
||||
|
||||
1. Use the [foam-template project](https://github.com/foambubble/foam-template) to generate a new repository. If you're logged into GitHub, you can just hit this button:
|
||||
1. **Create repository** - Use the [foam-template](https://github.com/foambubble/foam-template) to generate a new repository
|
||||
|
||||
<a class="github-button" href="https://github.com/foambubble/foam-template/generate" data-icon="octicon-repo-template" data-size="large" aria-label="Use this template foambubble/foam-template on GitHub">Use this template</a>
|
||||
|
||||
_If you want to keep your thoughts to yourself, remember to set the repository private, or if you don't want to use GitHub to host your workspace at all, choose [**Download as ZIP**](https://github.com/foambubble/foam-template/archive/main.zip) instead of **Use this template**._
|
||||
2. **Clone and open** - Clone locally and open the folder in VS Code
|
||||
3. **Install extensions** - Click "Install all" when prompted for recommended extensions
|
||||
4. **Configure** - Edit `.vscode/settings.json` for your preferences
|
||||
|
||||
2. [Clone the repository locally](https://help.github.com/en/github/creating-cloning-and-archiving-repositories/cloning-a-repository) and open it in VS Code.
|
||||
**Next steps:**
|
||||
|
||||
_Open the repository as a folder using the `File > Open...` menu item. In VS Code, "open workspace" refers to [multi-root workspaces](https://code.visualstudio.com/docs/editor/multi-root-workspaces)._
|
||||
|
||||
3. When prompted to install recommended extensions, click **Install all** (or **Show Recommendations** if you want to review and install them one by one)
|
||||
|
||||
After setting up the repository, open `.vscode/settings.json` and edit, add or remove any settings you'd like for your Foam workspace.
|
||||
|
||||
- _If using a [multi-root workspace](https://code.visualstudio.com/docs/editor/multi-root-workspaces) as noted above, make sure that your **Foam** directory is first in the list. There are some settings that will need to be migrated from `.vscode/settings.json` to your `.code-workspace` file._
|
||||
|
||||
To learn more about how to use **Foam**, read the [[recipes]].
|
||||
|
||||
Getting stuck in the setup? Read the [[frequently-asked-questions]].
|
||||
|
||||
Check our [issues on GitHub](http://github.com/foambubble/foam/issues) if you get stuck on something, and create a new one if something doesn't seem right!
|
||||
- Explore the [[recipes]] for usage patterns
|
||||
- Check [[frequently-asked-questions]] if you need help
|
||||
- Report issues on [GitHub](http://github.com/foambubble/foam/issues)
|
||||
|
||||
## Features
|
||||
|
||||
**Foam** doesn't have features in the traditional sense. Out of the box, you have access to all features of VS Code and all the [[recommended-extensions]] you choose to install, but it's up to you to discover what you can do with it!
|
||||
Foam leverages VS Code and [[recommended-extensions]] to provide:
|
||||
|
||||
- **Wikilinks** with autocomplete and navigation
|
||||
- **Backlinks** panel showing connections
|
||||
- **Graph visualization** of your knowledge network
|
||||
- **Daily notes** with templates and snippets
|
||||
- **Tag system** for organization
|
||||
- **Publishing** to static sites
|
||||
|
||||

|
||||
|
||||
Head over to [[recipes]] for some useful patterns and ideas!
|
||||
Explore [[recipes]] for usage patterns and workflows.
|
||||
|
||||
## Call To Adventure
|
||||
## Contributing
|
||||
|
||||
The goal of **Foam** is to be your personal companion on your quest for knowledge.
|
||||
Foam is an evolving project and we welcome contributions:
|
||||
|
||||
It's currently about "10% ready" relative to all the features I've thought of, but I've only thought of ~1% of the features it could have, and I'm excited to learn from others.
|
||||
|
||||
I am using it as my personal thinking tool. By making it public, I hope to learn from others not only how to improve Foam, but also to improve how I learn and manage information.
|
||||
|
||||
If that sounds like something you're interested in, I'd love to have you along on the journey.
|
||||
|
||||
- Read about our [[principles]] to understand Foam's philosophy and direction
|
||||
- Read the [[contribution-guide]] guide to learn how to participate.
|
||||
- Feel free to open [GitHub issues](https://github.com/foambubble/foam/issues) to give me feedback and ideas for new features.
|
||||
- Read our [[principles]] to understand Foam's philosophy
|
||||
- Follow the [[contribution-guide]] to get involved
|
||||
- Share feedback via [GitHub issues](https://github.com/foambubble/foam/issues)
|
||||
|
||||
## Thanks and attribution
|
||||
|
||||
@@ -272,6 +278,8 @@ If that sounds like something you're interested in, I'd love to have you along o
|
||||
<tr>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/s-jacob-powell"><img src="https://avatars.githubusercontent.com/u/109111499?v=4?s=60" width="60px;" alt="S. Jacob Powell"/><br /><sub><b>S. Jacob Powell</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=s-jacob-powell" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/figdavi"><img src="https://avatars.githubusercontent.com/u/99026991?v=4?s=60" width="60px;" alt="Davi Figueiredo"/><br /><sub><b>Davi Figueiredo</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=figdavi" title="Documentation">📖</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/ChThH"><img src="https://avatars.githubusercontent.com/u/9499483?v=4?s=60" width="60px;" alt="CT Hall"/><br /><sub><b>CT Hall</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=ChThH" title="Code">💻</a></td>
|
||||
<td align="center" valign="top" width="14.28%"><a href="https://github.com/meestahp"><img src="https://avatars.githubusercontent.com/u/177708514?v=4?s=60" width="60px;" alt="meestahp"/><br /><sub><b>meestahp</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=meestahp" title="Code">💻</a></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -281,20 +289,16 @@ If that sounds like something you're interested in, I'd love to have you along o
|
||||
|
||||
<!-- ALL-CONTRIBUTORS-LIST:END -->
|
||||
|
||||
**Foam** was inspired by [Roam Research](https://roamresearch.com/) and the [Zettelkasten methodology](https://zettelkasten.de/posts/overview)
|
||||
Foam was inspired by [Roam Research](https://roamresearch.com/) and [Zettelkasten methodology](https://zettelkasten.de/posts/overview).
|
||||
|
||||
**Foam** wouldn't be possible without [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/), and relies heavily on our fantastic open source [[recommended-extensions]] and all their contributors!
|
||||
Foam builds on [Visual Studio Code](https://code.visualstudio.com/), [GitHub](https://github.com/), and our [[recommended-extensions]].
|
||||
|
||||
## License
|
||||
|
||||
Foam is licensed under the [MIT license](LICENSE.txt).
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[graph-visualization]: user/features/graph-visualization.md "Graph Visualization"
|
||||
[backlinking]: user/features/backlinking.md "Backlinking"
|
||||
[recommended-extensions]: user/getting-started/recommended-extensions.md "Recommended Extensions"
|
||||
[recipes]: user/recipes/recipes.md "Recipes"
|
||||
[frequently-asked-questions]: user/frequently-asked-questions.md "Frequently Asked Questions"
|
||||
[principles]: principles.md "Principles"
|
||||
[contribution-guide]: dev/contribution-guide.md "Contribution Guide"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[recommended-extensions]: user/getting-started/recommended-extensions.md 'Recommended Extensions'
|
||||
[recipes]: user/recipes/recipes.md 'Recipes'
|
||||
[frequently-asked-questions]: user/frequently-asked-questions.md 'Frequently Asked Questions'
|
||||
[principles]: principles.md 'Principles'
|
||||
[contribution-guide]: dev/contribution-guide.md 'Contribution Guide'
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
# Reading list
|
||||
|
||||
- [Zettelkasten article, recommended by tchayen](https://github.com/alefore/weblog/blob/master/zettelkasten.md)
|
||||
- [Suping up VS Code as a Markdown editor](https://kortina.nyc/essays/suping-up-vs-code-as-a-markdown-notebook/)
|
||||
- [VSCode Extensions Packs](https://code.visualstudio.com/blogs/2017/03/07/extension-pack-roundup) [[todo]] Evaluate for deployment
|
||||
- [Dark mode](https://css-tricks.com/dark-modes-with-css/)
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[todo]: dev/todo.md "Todo"
|
||||
[//end]: # "Autogenerated link references"
|
||||
@@ -1,19 +0,0 @@
|
||||
# Terminology
|
||||
|
||||
It would be good to have some shared terminology to talk about Foam concepts. Some in-group terminology is acceptable, but we shouldn't be obtuse just to be exclusive.
|
||||
|
||||
Here's some ideas, these are open for discussion.
|
||||
|
||||
## Foam, the software project
|
||||
|
||||
The set of tools and ideas collected in this organisation.
|
||||
|
||||
## (Your) Foam
|
||||
|
||||
The directory/repository where you keep all your notes.
|
||||
|
||||
Also happens to sound quite a lot like Home. Funny, that.
|
||||
|
||||
## Bubble
|
||||
|
||||
Individual Foam note, written in Markdown.
|
||||
@@ -1,16 +1,66 @@
|
||||
# Backlinking
|
||||
# Backlinks
|
||||
|
||||
When using [[wikilinks]], you can find all notes that link to a specific note in the **Connections Explorer**
|
||||
Backlinks are one of Foam's most powerful features for knowledge discovery. They automatically show you which notes reference your current note, creating a web of interconnected knowledge that reveals surprising relationships between your ideas.
|
||||
|
||||
- Run `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), type "connections" and run the **Explorer: Focus on Connections** view.
|
||||
- Keep this pane always visible to discover relationships between your thoughts
|
||||
- You can drag the connections panel to a different section in VS Code if you prefer. See: [[make-backlinks-more-prominent]]
|
||||
- You can filter the connections to see just backlinks, forward links, or all connections
|
||||
- Finding backlinks in published Foam workspaces via [[materialized-backlinks]] is on the [[roadmap]] but not yet implemented.
|
||||
_[📹 Watch: Understanding and using backlinks in Foam]_
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[wikilinks]: wikilinks.md "Wikilinks"
|
||||
[make-backlinks-more-prominent]: ../recipes/make-backlinks-more-prominent.md "Make Backlinks More Prominent"
|
||||
[materialized-backlinks]: ../../dev/proposals/materialized-backlinks.md "Materialized Backlinks (stub)"
|
||||
[roadmap]: ../../dev/proposals/roadmap.md "Roadmap"
|
||||
[//end]: # "Autogenerated link references"
|
||||
## What Are Backlinks?
|
||||
|
||||
A backlink is a connection from another note that points to the note you're currently viewing. While you create forward links intentionally with `[[wikilinks]]`, backlinks are discovered automatically by Foam.
|
||||
|
||||
### Forward Links vs. Backlinks
|
||||
|
||||
**Forward Links** (what you create):
|
||||
|
||||
```markdown
|
||||
# Machine Learning Note
|
||||
|
||||
I'm studying [[Neural Networks]] and [[Deep Learning]] concepts.
|
||||
```
|
||||
|
||||
**Backlinks** (what Foam discovers):
|
||||
If you're viewing the "Neural Networks" note, Foam shows you that "Machine Learning Note" links to it, even though you didn't explicitly create that reverse connection.
|
||||
|
||||
This bidirectional linking creates a richer knowledge network than traditional hierarchical folders.
|
||||
|
||||
## Accessing Backlinks - Connections Panel
|
||||
|
||||
The Connections panel shows both forward links and backlinks:
|
||||
|
||||
1. **Open Command Palette** (`Ctrl+Shift+P` / `Cmd+Shift+P`)
|
||||
2. **Type "connections"** and select "Explorer: Focus on Connections"
|
||||
3. **Use the filter buttons** to show only backlinks, forward links, or all connections
|
||||
|
||||
_[📹 Watch: Finding and opening the backlinks panel]_
|
||||
|
||||
## Using Backlinks for Knowledge Discovery
|
||||
|
||||
### 1. Finding Unexpected Connections
|
||||
|
||||
Backlinks often reveal relationships you didn't consciously create:
|
||||
|
||||
**Example:** While reviewing a "Productivity" note, backlinks might show connections from:
|
||||
|
||||
- A cooking recipe (time management for meal prep)
|
||||
- A fitness routine (efficient workout planning)
|
||||
- A work project (team productivity strategies)
|
||||
|
||||
These diverse connections can spark new insights and cross-domain learning.
|
||||
|
||||
### 2. Identifying Important Concepts
|
||||
|
||||
Notes with many backlinks are often central to your thinking:
|
||||
|
||||
- **Hub concepts** that connect many ideas
|
||||
- **Frequently referenced** resources or definitions
|
||||
- **Bridge topics** that span multiple domains
|
||||
|
||||
### 3. Building Context Around Ideas
|
||||
|
||||
Backlinks provide context for how you use concepts across different areas:
|
||||
|
||||
- How you apply the same principle in various projects
|
||||
- Evolution of your thinking about a topic over time
|
||||
- Different perspectives you've encountered on the same idea
|
||||
|
||||
_[📹 Watch: Using backlinks for knowledge discovery and research]_
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
# Built-In Note Embedding Types
|
||||
|
||||
When embedding a note, there are a few ways to modify the scope of the content as well as its display style. The following are Foam keywords that are used to describe note embedding.
|
||||
|
||||
Note, this only applies to note embedding, not embedding of attachments or images.
|
||||
|
||||

|
||||
|
||||
## Scope
|
||||
|
||||
- `full` - the entire note in the case of `![[note]]` or the entire section in the case of `![[note#section1]]`
|
||||
- `content` - everything excluding the title of the section. So the entire note minus the title for `![[note]]`, or the entire section minus the section header for `![[note#section1]]`
|
||||
|
||||
## Style
|
||||
|
||||
- `card` - outlines the embedded note with a border
|
||||
- `inline` - adds the note continuously as if the text were part of the calling note
|
||||
|
||||
## Default Setting
|
||||
|
||||
Foam expresses note display type as `<scope>-<style>`.
|
||||
|
||||
By default, Foam configures note embedding to be `full-card`. That is, whenever the standard embedding syntax is used, `![[note]]`, the note will have `full` scope and `card` style display. This setting is stored under `foam.preview.embedNoteStyle` and can be modified.
|
||||
|
||||
## Explicit Modifiers
|
||||
|
||||
Prepend the wikilink with one of the scope or style keywords, or a combination of the two to explicitly modify a note embedding if you would like to override the default setting.
|
||||
|
||||
For example, given your `foam.embedNoteStyle` is set to `content-card`, embedding a note with standard syntax `![[note-a]]` would show a bordered note without its title. Say, for a specific `note-b` you would like to display the title. You can simply use one of the above keywords to override your default setting like so: `full![[note-b]]`. In this case, `full` overrides the default `content` scope and because a style is not specified, it falls back to the default style setting, `card`. If you would like it to be inline, override that as well: `full-inline![[note-b]]`.
|
||||
@@ -71,3 +71,25 @@ Examples:
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Link Conversion Commands
|
||||
|
||||
Foam provides commands to convert between wikilink and markdown link formats.
|
||||
|
||||
### foam-vscode.convert-wikilink-to-mdlink
|
||||
|
||||
Converts a wikilink at the cursor position to markdown link format with a relative path.
|
||||
|
||||
Example: `[[my-note]]` → `[My Note](../path/to/my-note.md)`
|
||||
|
||||
### foam-vscode.convert-mdlink-to-wikilink
|
||||
|
||||
Converts a markdown link at the cursor position to wikilink format.
|
||||
|
||||
Example: `[My Note](../path/to/my-note.md)` → `[[my-note]]`
|
||||
|
||||
**Usage:**
|
||||
|
||||
1. Place your cursor inside a wikilink or markdown link
|
||||
2. Open the command palette (`Ctrl+Shift+P` / `Cmd+Shift+P`)
|
||||
3. Type "Foam: Convert" and select the desired conversion command
|
||||
|
||||
@@ -1,23 +1,48 @@
|
||||
# Daily Notes
|
||||
|
||||
Daily notes allow you to quickly create and access a new notes file for each day. This is a surprisingly effective and increasingly common strategy to organize notes and manage events.
|
||||
Daily notes allow you to quickly create and access a note file for each day.
|
||||
|
||||
View today's note file by running the `Foam: Open Daily Note` command, by using the shortcut `alt+d` (note: shortcuts can be [overridden](https://code.visualstudio.com/docs/getstarted/keybindings)), or by using [#snippets](#Snippets). The name, location, and title of daily notes files are [#configurable](#Configuration).
|
||||
## Creating Daily Notes
|
||||
|
||||
## Roam-style Automatic Daily Notes
|
||||
- **Command:** `Ctrl+Shift+P` → "Foam: Open Daily Note"
|
||||
- **Shortcut:** `Alt+D`
|
||||
- **Snippets:** Type `/today`, `/yesterday`, `/tomorrow` in any note
|
||||
|
||||
You can automatically open today's note on startup by setting the `Foam › Open Daily Note: On Startup` setting to `true`.
|
||||
## Automatic Daily Notes
|
||||
|
||||
Open daily note automatically on VS Code startup:
|
||||
|
||||
```json
|
||||
{
|
||||
"foam.openDailyNote.onStartup": true
|
||||
}
|
||||
```
|
||||
|
||||
## Daily Note Templates
|
||||
|
||||
Daily notes can also make use of [[Note Templates]], by defining a special `.foam/templates/daily-note.md` template.
|
||||
Create `.foam/templates/daily-note.md` to customize the structure:
|
||||
|
||||
## Snippets
|
||||
```markdown
|
||||
---
|
||||
type: daily-note
|
||||
---
|
||||
|
||||
Create a link to a recent daily note using [snippets](https://code.visualstudio.com/docs/editor/userdefinedsnippets). Type `/today` and press `enter` to link to today's note. You can also write:
|
||||
# Daily Note - $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE
|
||||
|
||||
## Tasks
|
||||
|
||||
- [ ]
|
||||
|
||||
## Notes
|
||||
```
|
||||
|
||||
## Date Snippets
|
||||
|
||||
Create links to recent daily notes using snippets:
|
||||
|
||||
| Snippet | Date |
|
||||
| ------------ | ------------- |
|
||||
| `/today` | today |
|
||||
| `/tomorrow` | tomorrow |
|
||||
| `/yesterday` | yesterday |
|
||||
| `/monday` | next Monday |
|
||||
@@ -29,31 +54,13 @@ Create a link to a recent daily note using [snippets](https://code.visualstudio.
|
||||
|
||||
## Configuration
|
||||
|
||||
By default, Daily Notes will be created in a file called `yyyy-mm-dd.md` in the workspace's `journals` folder, with the heading `yyyy-mm-dd`.
|
||||
By default, daily notes are created as `yyyy-mm-dd.md` in the workspace's `journals` folder.
|
||||
|
||||
These settings can be overridden in your workspace or global `.vscode/settings.json` file, using the [**dateformat** date masking syntax](https://github.com/felixge/node-dateformat#mask-options):
|
||||
To customize your daily note location and format you can create a `.foam/templates/daily-note.md` template. See [[templates]] for more information.
|
||||
|
||||
It's possible to customize the path and heading of your daily notes, by following the [dateformat masking syntax](https://github.com/felixge/node-dateformat#mask-options).
|
||||
The following properties can be used:
|
||||
There are also some settings to customize the behavior of daily notes, but they are deprecated and will be removed. Please use the `daily-note.md` template.
|
||||
|
||||
```json
|
||||
"foam.openDailyNote.directory": "journal",
|
||||
"foam.openDailyNote.filenameFormat": "'daily-note'-yyyy-mm-dd",
|
||||
"foam.openDailyNote.fileExtension": "mdx",
|
||||
"foam.openDailyNote.titleFormat": "'Journal Entry, ' dddd, mmmm d",
|
||||
```
|
||||
|
||||
The above configuration would create a file `journal/daily-note-2020-07-25.mdx`, with the heading `Journal Entry, Sunday, July 25`.
|
||||
|
||||
> NOTE: It is possible to set the filepath of a daily note according to the date using the special [[note-properties]] configurable for [[Note Templates]]. Specifically, see [[note-templates#Example of date-based|Example of date-based filepath]]. Using the template property will override any setting configured through `.vscode/settings.json`.
|
||||
|
||||
## Extend Functionality (Weekly, Monthly, Quarterly Notes)
|
||||
|
||||
Please see [[note-macros]]
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[Note Templates]: note-templates.md "Note Templates"
|
||||
[note-properties]: note-properties.md "Note Properties"
|
||||
[note-templates#Example of date-based|Example of date-based filepath]: note-templates.md "Note Templates"
|
||||
[note-macros]: ../recipes/note-macros.md "Custom Note Macros"
|
||||
[note-templates]: note-templates.md "Note Templates"
|
||||
[//end]: # "Autogenerated link references"
|
||||
|
||||
134
docs/user/features/embeds.md
Normal file
134
docs/user/features/embeds.md
Normal file
@@ -0,0 +1,134 @@
|
||||
# Note Embeds
|
||||
|
||||
Embeds allow you to include content from other notes directly into your current note. This is powerful for creating dynamic content that updates automatically when the source note changes.
|
||||
|
||||
## Basic Syntax
|
||||
|
||||
Use the embed syntax with an exclamation mark before the wikilink:
|
||||
|
||||
```markdown
|
||||
![[note-name]]
|
||||
```
|
||||
|
||||
This will embed the entire content of `note-name` into your current note.
|
||||
|
||||
## Embedding Sections
|
||||
|
||||
You can embed specific sections of a note by referencing the heading:
|
||||
|
||||
```markdown
|
||||
![[note-name#Section Title]]
|
||||
```
|
||||
|
||||
## Embed Types
|
||||
|
||||
Foam supports different embedding scopes and styles that can be configured globally or overridden per embed.
|
||||
|
||||
### Scope Modifiers
|
||||
|
||||
- **`full`** - Include the entire note or section, including the title/heading
|
||||
- **`content`** - Include everything except the title/heading
|
||||
|
||||
Examples:
|
||||
|
||||
```markdown
|
||||
full![[my-note]] # Include title + content
|
||||
content![[my-note]] # Content only, no title
|
||||
```
|
||||
|
||||
### Style Modifiers
|
||||
|
||||
- **`card`** - Display the embedded content in a bordered container
|
||||
- **`inline`** - Display the content seamlessly as part of the current note
|
||||
|
||||
Examples:
|
||||
|
||||
```markdown
|
||||
card![[my-note]] # Bordered container
|
||||
inline![[my-note]] # Seamless integration
|
||||
```
|
||||
|
||||
### Combined Modifiers
|
||||
|
||||
You can combine scope and style modifiers:
|
||||
|
||||
```markdown
|
||||
full-card![[my-note]] # Title + content in bordered container
|
||||
content-inline![[my-note]] # Content only, seamlessly integrated
|
||||
full-inline![[my-note]] # Title + content, seamlessly integrated
|
||||
content-card![[my-note]] # Content only in bordered container
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Set your default embed behavior in VS Code settings:
|
||||
|
||||
```json
|
||||
{
|
||||
"foam.preview.embedNoteType": "full-card"
|
||||
}
|
||||
```
|
||||
|
||||
Available options:
|
||||
|
||||
- `full-card` (default)
|
||||
- `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`
|
||||
@@ -1,10 +1,23 @@
|
||||
# Graph Visualization
|
||||
|
||||
Foam comes with a graph visualization of your notes.
|
||||
The graph view is one of Foam's most powerful features. It transforms your collection of notes into a visual network, revealing connections between ideas that might not be obvious when reading individual notes. This guide will teach you how to use the graph view to explore, understand, and expand your knowledge base.
|
||||
|
||||
To see the graph execute the `Foam: Show Graph` command.
|
||||
|
||||
Your files, such as notes and documents, are shown as the nodes of the graph along with the tags defined in your notes. The edges of the graph represent either a link between two files or a file that contains a certain tag. A node in the graph will grow in size with the number of connections it has, representing stronger or more defined concepts and topics.
|
||||
|
||||
### The `Show Graph` command
|
||||
|
||||
1. **Press `Ctrl+Shift+P` / `Cmd+Shift+P`**
|
||||
2. **Type "Foam: Show Graph"**
|
||||
3. **Press Enter**
|
||||
|
||||
You can set up a custom keyboard shortcut:
|
||||
|
||||
1. **Go to File > Preferences > Keyboard Shortcuts**
|
||||
2. **Search for "Foam: Show Graph"**
|
||||
3. **Assign your preferred shortcut**
|
||||
|
||||
## Graph Navigation
|
||||
|
||||
With the Foam graph visualization you can:
|
||||
@@ -101,8 +114,19 @@ Will result in the following graph:
|
||||
|
||||

|
||||
|
||||
## What's Next?
|
||||
|
||||
With graph view mastery, you're ready to explore advanced Foam features:
|
||||
|
||||
1. **[[wikilinks]]** - Understand bidirectional connections
|
||||
2. **[[templates]]** - Use templates effectively to standardize your note creation
|
||||
3. **[[tags]]** - Organize your notes with tags
|
||||
4. **[[daily-notes]]** - Set up daily notes to establish capture routines
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[note-properties]: note-properties.md "Note Properties"
|
||||
[wikilinks]: wikilinks.md "Wikilinks"
|
||||
[tags]: tags.md "Tags"
|
||||
[templates]: templates.md "Note Templates"
|
||||
[daily-notes]: daily-notes.md "Daily Notes"
|
||||
[//end]: # "Autogenerated link references"
|
||||
@@ -1,23 +0,0 @@
|
||||
# Including notes in a note
|
||||
|
||||
In some situations it might be useful to include the content of another note in your current note. Foam supports this displaying within the vscode environment. Note, this does not work out-of-the-box for your publishing solutions.
|
||||
|
||||
## Including a note
|
||||
|
||||
Including a note can be done by adding an `!` before a wikilink definition. For example `![[wikilink]]`.
|
||||
|
||||
## Custom styling
|
||||
|
||||
To modify how an embedded note looks and the scope of its content, see [[built-in-note-embedding-types]]
|
||||
|
||||
For more fine-grained custom styling, see [[custom-markdown-preview-styles]]
|
||||
|
||||
## Future possibilities
|
||||
|
||||
Work on this feature is evolving and progressing. See the [[inclusion-of-notes]] proposal for the current discussion.
|
||||
|
||||
[//begin]: # 'Autogenerated link references for markdown compatibility'
|
||||
[built-in-note-embedding-types]: built-in-note-embedding-types.md 'Built-In Note Embedding Types'
|
||||
[custom-markdown-preview-styles]: custom-markdown-preview-styles.md 'Custom Markdown Preview Styles'
|
||||
[inclusion-of-notes]: ../../dev/proposals/inclusion-of-notes.md 'Inclusion of notes Proposal '
|
||||
[//end]: # 'Autogenerated link references'
|
||||
@@ -1,88 +1,60 @@
|
||||
# Link Reference Definitions
|
||||
|
||||
When you use `[[wikilinks]]`, the [foam-vscode](https://github.com/foambubble/foam/tree/main/packages/foam-vscode) extension can automatically generate [Markdown Link Reference Definitions](https://spec.commonmark.org/0.29/#link-reference-definitions) at the bottom of the file. This is not needed to navigate your workspace with foam-vscode, but is useful for files to remain compatible with various Markdown tools (e.g. parsers, static site generators, VS code plugins etc), which don't support `[[wikilinks]]`.
|
||||
Link reference definitions make your notes compatible with standard Markdown processors by converting wikilinks to standard Markdown references.
|
||||
|
||||
## Example
|
||||
Foam doesn't need references in order to work, but this feature is aimed at supporting other tools you might want to integrate with.
|
||||
|
||||
The following example:
|
||||
## What Are Link Reference Definitions?
|
||||
|
||||
```md
|
||||
- [[wikilinks]]
|
||||
- [[github-pages]]
|
||||
Foam can automatically add reference definitions to the bottom of your notes:
|
||||
|
||||
**Your note:**
|
||||
|
||||
```markdown
|
||||
# Machine Learning
|
||||
|
||||
Related to [[Data Science]] and [[Statistics]].
|
||||
```
|
||||
|
||||
...generates the following link reference definitions to the bottom of the file:
|
||||
**With reference definitions:**
|
||||
|
||||
```md
|
||||
[wikilinks]: wikilinks 'Wikilinks'
|
||||
[github-pages]: github-pages 'GitHub Pages'
|
||||
```markdown
|
||||
# Machine Learning
|
||||
|
||||
Related to [[Data Science]] and [[Statistics]].
|
||||
|
||||
[Data Science]: data-science.md 'Data Science'
|
||||
[Statistics]: statistics.md 'Statistics'
|
||||
```
|
||||
|
||||
You can open the [raw markdown](https://foambubble.github.io/foam/user/features/link-reference-definitions.md) to see them at the bottom of this file
|
||||
## Enabling Reference Definitions
|
||||
|
||||
## Specification
|
||||
|
||||
The three components of a link reference definition are `[link-label]: link-target "Link Title"`
|
||||
|
||||
- **link label:** The link text to match in the surrounding markdown document. This matches the inner bracket of the double-bracketed `[[wikilink]]` notation
|
||||
- **link destination** The target of the matched link
|
||||
- By default we generate links without extension. This can be overridden, see [Configuration](#configuration) below
|
||||
- **"Link Title"** Optional title for link (The Foam template has a snippet of JavaScript to replace this on the website at runtime)
|
||||
|
||||
## Configuration
|
||||
|
||||
You can choose to generate link reference definitions with or without file extensions, depending on the target, or to disable the generation altogether. As a rule of thumb:
|
||||
|
||||
- Links with file extensions work better with standard markdown-based tools, such as GitHub web UI.
|
||||
- Links without file extensions work better with certain web publishing tools that treat links as literal urls and don't transform them automatically, such as the standard GitHub pages installation.
|
||||
|
||||
By default, Foam generates links without file extensions for legacy reasons, but this may change in future versions.
|
||||
|
||||
You can override this setting in your Foam workspace's `settings.json`:
|
||||
|
||||
- `"foam.edit.linkReferenceDefinitions": "withoutExtensions"` (default)
|
||||
- `"foam.edit.linkReferenceDefinitions": "withExtensions"`
|
||||
- `"foam.edit.linkReferenceDefinitions": "off"`
|
||||
|
||||
### Ignoring files
|
||||
|
||||
Sometimes, you may want to ignore certain files or folders, so that Foam doesn't generate link reference definitions to them.
|
||||
|
||||
There are three options for excluding files from your Foam project:
|
||||
|
||||
1. `files.exclude` (from VSCode) will prevent the folder from showing in the file explorer.
|
||||
|
||||
> "Configure glob patterns for excluding files and folders. For example, the file explorer decides which files and folders to show or hide based on this setting. Refer to the Search: Exclude setting to define search-specific excludes."
|
||||
|
||||
2. `files.watcherExclude` (from VSCode) prevents VSCode from constantly monitoring files for changes.
|
||||
|
||||
> "Configure paths or glob patterns to exclude from file watching. Paths or basic glob patterns that are relative (for example `build/output` or `*.js`) will be resolved to an absolute path using the currently opened workspace. Complex glob patterns must match on absolute paths (i.e. prefix with `**/` or the full path and suffix with `/**` to match files within a path) to match properly (for example `**/build/output/**` or `/Users/name/workspaces/project/build/output/**`). When you experience the file watcher process consuming a lot of CPU, make sure to exclude large folders that are of less interest (such as build output folders)."
|
||||
|
||||
3. `foam.files.ignore` (from Foam) ignores files from being added to the Foam graph.
|
||||
|
||||
> "Specifies the list of globs that will be ignored by Foam (e.g. they will not be considered when creating the graph). To ignore the all the content of a given folder, use `<folderName>/**/*`" (requires reloading VSCode to take effect).
|
||||
|
||||
For instance, if you're using a local instance of [Jekyll](https://jekyllrb.com/), you may find that it writes copies of each `.md` file into a `_site` directory, which may lead to Foam generating references to them instead of the original source notes.
|
||||
|
||||
You can ignore the `_site` directory by adding any of the following settings to your `.vscode/settings.json` file:
|
||||
Configure in your settings:
|
||||
|
||||
```json
|
||||
"files.exclude": {
|
||||
"**/_site": true
|
||||
},
|
||||
"files.watcherExclude": {
|
||||
"**/_site": true
|
||||
},
|
||||
"foam.files.ignore": [
|
||||
"_site/**/*"
|
||||
]
|
||||
{
|
||||
"foam.edit.linkReferenceDefinitions": "withExtensions"
|
||||
}
|
||||
```
|
||||
|
||||
After changing the setting in your workspace, you can run the [[workspace-janitor]] command to convert all existing definitions.
|
||||
**Options:**
|
||||
|
||||
See [[link-reference-definition-improvements]] for further discussion on current problems and potential solutions.
|
||||
- `"off"` - Disabled (default)
|
||||
- `"withoutExtensions"` - References without extension
|
||||
- `"withExtensions"` - References with extension
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[workspace-janitor]: ../tools/workspace-janitor.md "Janitor"
|
||||
[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md "Link Reference Definition Improvements"
|
||||
[//end]: # "Autogenerated link references"
|
||||
If you are using your notes only within Foam, you can keep definitions `off` (also to reduce clutter), otherwise pick your setting based on what is required by your use case.
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Scans your note for wikilinks
|
||||
2. Generates reference definitions when you save
|
||||
3. Updates definitions when links change
|
||||
4. Maintains the auto-generated section
|
||||
|
||||
## Benefits
|
||||
|
||||
- **Standard Markdown compatibility** - Works with any Markdown processor
|
||||
- **Publishing platforms** - Compatible with GitHub Pages, Jekyll, etc.
|
||||
- **Future-proofing** - Not locked into Foam-specific format
|
||||
- **Team collaboration** - Others can read notes without Foam
|
||||
|
||||
@@ -27,12 +27,12 @@ This sets the `type` of this document to `feature` and sets **three** keywords f
|
||||
|
||||
Some properties have special meaning for Foam:
|
||||
|
||||
| Name | Description |
|
||||
| ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | will assign the name to the note that you will see in the graph, regardless of the filename or the first heading (also see how to [[write-notes-in-foam]]) |
|
||||
| `type` | can be used to style notes differently in the graph (also see [[graph-visualization]]). The default type for a document is `note` unless otherwise specified with this property. |
|
||||
| `tags` | can be used to add tags to a note (see [[tags]]) |
|
||||
| `alias` | can be used to add aliases to the note. an alias will show up in the link autocompletion |
|
||||
| Name | Description |
|
||||
| ------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `title` | will assign the name to the note that you will see in the graph, regardless of the filename or the first heading (also see how to [[note-taking-in-foam]]) |
|
||||
| `type` | can be used to style notes differently in the graph (also see [[graph-view]]). The default type for a document is `note` unless otherwise specified with this property. |
|
||||
| `tags` | can be used to add tags to a note (see [[tags]]) |
|
||||
| `alias` | can be used to add aliases to the note. an alias will show up in the link autocompletion |
|
||||
|
||||
For example:
|
||||
|
||||
@@ -47,11 +47,12 @@ alias: alias1, alias2
|
||||
|
||||
## Foam Template Properties
|
||||
|
||||
There also exists properties that are even more specific to Foam templates, see [[note-templates#Metadata]] for more info.
|
||||
There also exists properties that are even more specific to Foam templates, see [[templates#Metadata]] for more info.
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[write-notes-in-foam]: ../getting-started/write-notes-in-foam.md "Writing Notes"
|
||||
[graph-visualization]: graph-visualization.md "Graph Visualization"
|
||||
[tags]: tags.md "Tags"
|
||||
[graph-view]: ../features/graph-view.md "Graph Visualization"
|
||||
[note-taking-in-foam]: ../getting-started/note-taking-in-foam.md "Note-Taking in Foam"
|
||||
[note-templates#Metadata]: note-templates.md "Note Templates"
|
||||
[//end]: # "Autogenerated link references"
|
||||
|
||||
@@ -1,62 +1,90 @@
|
||||
---
|
||||
tags: my-tag1 my-tag2 my-tag3/notes
|
||||
---
|
||||
|
||||
# Tags
|
||||
|
||||
You can add tags to your notes to categorize or link notes together.
|
||||
Tags provide flexible categorization and organization for your notes beyond wikilinks and folders.
|
||||
|
||||
## Creating a tag
|
||||
## Creating Tags
|
||||
|
||||
There are two ways of creating a tag:
|
||||
### Inline Tags
|
||||
|
||||
- Adding a `#tag` anywhere in the text of the note, for example: #my-tag1
|
||||
- Using the `tags: tag1, tag2` yaml frontmatter [[note-properties|note property]]. Notice `my-tag1` and `my-tag2` tags which are added to this document this way.
|
||||
Add tags directly in note content:
|
||||
|
||||
Tags can also be hierarchical, so you can have `#parent/child` such as #my-tag3/info.
|
||||
```markdown
|
||||
# Machine Learning Fundamentals
|
||||
|
||||
### Tag completion
|
||||
This covers basic algorithms and applications.
|
||||
|
||||
Typing the `#` character will launch VS Code's "Intellisense." This provider will show a list of possible tags that match the character. If you are editing in the frontmatter [[note-properties|note property]], you can invoke tag completion on the `tags:` line by either typing the `#` character, or using the ["trigger suggest"](https://code.visualstudio.com/docs/editor/intellisense) keybinding (usually `ctrl+space`). If the `#` is used in the frontmatter, it will be removed when the tag is inserted.
|
||||
|
||||
## Using *Tag Explorer*
|
||||
|
||||
It's possible to navigate tags via the Tag Explorer panel. Expand the Tag Explorer view in the left side bar which will list all the tags found in current Foam environment. Then, each level of tags can be expanded until the options to search by tag and a list of all files containing a particular tag are shown.
|
||||
|
||||
Tags can also be visualized in the Foam Graph Explorer. See [[graph-visualization]] for more info including how to change the color of nodes representing tags.
|
||||
|
||||
## Styling tags
|
||||
|
||||
It is possible to customize the way that tags look in the Markdown Preview panel that renders your Foam notes. This requires some knowledge of the CSS language, which is used to customize the styles of web technologies such as VSCode. A cursory introduction to CSS can be [found here](https://www.freecodecamp.org/news/get-started-with-css-in-5-minutes-e0804813fc3e/).
|
||||
|
||||
1. Create a CSS file within your Foam project, for example in `.foam/css/custom-tag-style.css` or [.vscode/custom-tag-style.css](../../.vscode/custom-tag-style.css)
|
||||
2. Add CSS code that targets the `.foam-tag` class
|
||||
3. Add a rule for each [CSS property](https://www.w3schools.com/cssref/index.php) you would like applied to your tags.
|
||||
4. Open the `.vscode/settings.json` file (or the Settings browser with `ctrl+,`)
|
||||
5. Add the path to your new stylesheet to the `markdown.styles` setting.
|
||||
|
||||
> Note: the file path for the stylesheet will be relative to the currently open folder in the workspace when changing this setting for the current workspace. If changing this setting for the user, then the file path will be relative to your global [VSCode settings](https://code.visualstudio.com/docs/getstarted/settings).
|
||||
|
||||
The end result will be a CSS file that looks similar to the content below. Now you can make your tags standout in your note previews.
|
||||
|
||||
```css
|
||||
.foam-tag{
|
||||
color:#ffffff;
|
||||
background-color: #000000;
|
||||
}
|
||||
#machine-learning #data-science #algorithms #beginner
|
||||
```
|
||||
|
||||

|
||||
### Front Matter Tags
|
||||
|
||||
## Using backlinks in place of tags
|
||||
Add tags in YAML front matter:
|
||||
|
||||
Given the power of backlinks, some people prefer to use them as tags.
|
||||
For example you can tag your notes about books with [[book]].
|
||||
```markdown
|
||||
---
|
||||
tags: [machine-learning, data-science, algorithms, beginner]
|
||||
---
|
||||
```
|
||||
|
||||
[note-properties|note property]: note-properties.md "Note Properties"
|
||||
[graph-visualization]: graph-visualization.md "Graph Visualization"
|
||||
### Hierarchical Tags
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[note-properties|note property]: note-properties.md "Note Properties"
|
||||
[graph-visualization]: graph-visualization.md "Graph Visualization"
|
||||
[//end]: # "Autogenerated link references"
|
||||
Create tag hierarchies using forward slashes:
|
||||
|
||||
```markdown
|
||||
#programming/languages/python
|
||||
#programming/frameworks/react
|
||||
#work/projects/website-redesign
|
||||
#personal/health/exercise
|
||||
```
|
||||
|
||||
## Autocompletion
|
||||
|
||||
Typing `#` shows existing tags. In front matter, use `Ctrl+Space` for tag suggestions.
|
||||
|
||||
## Tag Explorer
|
||||
|
||||
Use the Tag Explorer panel in VS Code's sidebar to:
|
||||
|
||||
- Browse hierarchical tag structure
|
||||
- Filter by tag names
|
||||
- Click tags to see all associated notes
|
||||
- View tag usage counts
|
||||
- Search for tags (click the search icon or use "Foam: Search Tag" command)
|
||||
|
||||
Tags also appear in the [[graph-view]] with customizable colors.
|
||||
|
||||
## Tag Search
|
||||
|
||||
Search for all occurrences of a tag across your workspace:
|
||||
|
||||
1. Use the command palette: "Foam: Search Tag"
|
||||
2. Or click the search icon next to a tag in the Tag Explorer panel
|
||||
|
||||
Results appear in VS Code's search panel where you can navigate between matches.
|
||||
|
||||
> Known limitation: this command leverages VS Code's search capability, so it's constrained by its use of regular expressions. The search is best-effort and some false search results might show up.
|
||||
|
||||
## Custom Tag Styling
|
||||
|
||||
Customize tag appearance in markdown preview by adding CSS:
|
||||
|
||||
1. Create `.foam/css/custom-tag-style.css`
|
||||
2. Add CSS targeting `.foam-tag` class:
|
||||
```css
|
||||
.foam-tag {
|
||||
color: #ffffff;
|
||||
background-color: #000000;
|
||||
}
|
||||
```
|
||||
3. Update `.vscode/settings.json`:
|
||||
```json
|
||||
{
|
||||
"markdown.styles": [".foam/css/custom-tag-style.css"]
|
||||
}
|
||||
```
|
||||
|
||||
## Tags vs Backlinks
|
||||
|
||||
Some users prefer [[book]] backlinks instead of #book tags for categorization. Both approaches work - choose what fits your workflow.
|
||||
|
||||
[graph-view]: graph-view.md 'Graph Visualization'
|
||||
|
||||
@@ -240,19 +240,32 @@ 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_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_WEEK_YEAR`: the year of the ISO 8601 week number. The year that contains the Thursday of the current week, may vary from calendar year near Jan 1. Often used with `FOAM_DATE_WEEK`.
|
||||
- `FOAM_DATE_DAY_ISO`: ISO 8601 weekday number (1-7, where Monday=1, Sunday=7)
|
||||
- `FOAM_DATE_DATE`: 2-digit day of month (e.g. 15)
|
||||
- `FOAM_DATE_DAY_NAME`: Full weekday name (e.g. Monday)
|
||||
- `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.
|
||||
|
||||
@@ -306,6 +319,30 @@ foam_template:
|
||||
# $FOAM_DATE_YEAR-$FOAM_DATE_MONTH-$FOAM_DATE_DATE Daily Notes
|
||||
```
|
||||
|
||||
##### Creating notes in the current directory
|
||||
|
||||
To create notes in the same directory as your currently active file, use the `FOAM_CURRENT_DIR` variable in your template's `filepath`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
foam_template:
|
||||
name: Current Directory Note
|
||||
filepath: '$FOAM_CURRENT_DIR/$FOAM_SLUG.md'
|
||||
---
|
||||
|
||||
# $FOAM_TITLE
|
||||
|
||||
$FOAM_SELECTED_TEXT
|
||||
```
|
||||
|
||||
**Best practices for filepath patterns:**
|
||||
|
||||
- **Explicit current directory:** `$FOAM_CURRENT_DIR/$FOAM_SLUG.md` - Creates notes in the current editor's directory
|
||||
- **Workspace root:** `/$FOAM_SLUG.md` - Always creates notes in workspace root
|
||||
- **Subdirectories:** `$FOAM_CURRENT_DIR/meetings/$FOAM_SLUG.md` - Creates notes in subdirectories relative to current location
|
||||
|
||||
The `FOAM_CURRENT_DIR` approach is recommended over relative paths (like `./file.md`) because it makes the template's behavior explicit and doesn't depend on configuration settings.
|
||||
|
||||
#### `name` and `description` attributes
|
||||
|
||||
These attributes provide a human readable name and description to be shown in the template picker (e.g. When a user uses the `Foam: Create New Note From Template` command):
|
||||
@@ -361,6 +398,4 @@ existing_frontmatter: 'Existing Frontmatter block'
|
||||
This is the rest of the template
|
||||
```
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[daily-notes]: daily-notes.md "Daily Notes"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[daily-notes]: daily-notes.md 'Daily Notes'
|
||||
@@ -1,44 +1,43 @@
|
||||
# Wikilinks
|
||||
|
||||
Wikilinks are the internal links that connect the files in your knowledge base. (Also called `[[MediaWiki]]` links).
|
||||
Wikilinks are internal links that connect files in your knowledge base using `[[double bracket]]` syntax.
|
||||
|
||||
## Creating and navigating wikilinks
|
||||
## Creating Wikilinks
|
||||
|
||||
To create a wikilink, type `[[` and then start typing the name of another note in your repo. Once the desired note is selected press the `tab` key to autocomplete it. For example: [[graph-visualization]].
|
||||
1. **Type `[[`** and start typing a note name
|
||||
2. **Select from autocomplete** and press `Tab`
|
||||
3. **Navigate** with `Ctrl+Click` (`Cmd+Click` on Mac) or `F12`
|
||||
4. **Create new notes** by clicking on non-existent wikilinks
|
||||
|
||||
`Cmd` + `Click` ( `Ctrl` + `Click` on Windows ) on wikilink to navigate to that note (`F12` also works while your cursor is on the wikilink). If the file doesn't exist it will be created in your workspace based on your default [[note-templates]] settings.
|
||||
Example: [[graph-view]]
|
||||
|
||||
## Placeholders
|
||||
|
||||
You can also create a [[placeholder]]. <!--NOTE: this placeholder link should NOT have an associated file. This is to demonstrate the concept-->
|
||||
A placeholder is a wikilink that doesn't have a target file and a link to a placeholder is styled differently so you can easily tell them apart.
|
||||
They can still be helpful to highlight connections.
|
||||
Wikilinks to non-existent files create [[placeholder]] links, styled differently to show they need files created. They're useful for planning your knowledge structure.
|
||||
|
||||
Open the graph with `Foam: Show Graph` command, and look at the placeholder node.
|
||||
View placeholders in the graph with `Foam: Show Graph` command or in the `Placeholders` panel.
|
||||
|
||||
Remember, with `CTRL/CMD+click` on a wikilink you can navigate to the note, or create it (if the link is a placeholder).
|
||||
## Section Links
|
||||
|
||||
## Support for sections
|
||||
Link to specific sections using `[[note-name#Section Title]]` syntax. Foam provides autocomplete for section titles.
|
||||
|
||||
Foam supports autocompletion, navigation, embedding and diagnostics for note sections. Just use the standard wiki syntax of `[[resource#Section Title]]`.
|
||||
- If it's an external file, `[your link will need the filename](other-file.md#that-section-I-want-to-link-to)`, but
|
||||
- if it's an anchor within the same document, `[you just need an octothorpe and the section name](#that-section-above)`.
|
||||
- Doesn't matter what heading-level the anchor is; whether you're linking to an `H1` like `# MEN WALK ON MOON` or an `H2` like `## Astronauts Land on Plain`, the link syntax uses a single octothorpe: `[Walk!](#men-walk-on-moon)` and `[Land!](#astronauts-land-on-plain-collect-rocks-plant-flag)`. Autocomplete is your friend here.
|
||||
Examples:
|
||||
|
||||
## Markdown compatibility
|
||||
- External file: `[link text](other-file.md#section-name)`
|
||||
- Same document: `[link text](#section-name)`
|
||||
|
||||
The [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) extension automatically generates [[link-reference-definitions]] at the bottom of the file to make wikilinks compatible with other Markdown tools and parsers.
|
||||
## Markdown Compatibility
|
||||
|
||||
## Read more
|
||||
Foam can automatically generate [[link-reference-definitions]] at the bottom of files to make wikilinks compatible with standard Markdown processors.
|
||||
|
||||
- [[foam-file-format]]
|
||||
- [[note-templates]]
|
||||
- See [[link-reference-definition-improvements]] for further discussion on current problems and potential solutions.
|
||||
## Related
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[graph-visualization]: graph-visualization.md "Graph Visualization"
|
||||
[note-templates]: note-templates.md "Note Templates"
|
||||
[link-reference-definitions]: link-reference-definitions.md "Link Reference Definitions"
|
||||
[foam-file-format]: ../../dev/foam-file-format.md "Foam File Format"
|
||||
[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md "Link Reference Definition Improvements"
|
||||
[//end]: # "Autogenerated link references"
|
||||
- [[foam-file-format]] - Technical details
|
||||
- [[templates]] - Creating new notes
|
||||
- [[link-reference-definition-improvements]] - Current limitations
|
||||
|
||||
[graph-visualization]: graph-visualization.md 'Graph Visualization'
|
||||
[link-reference-definitions]: link-reference-definitions.md 'Link Reference Definitions'
|
||||
[foam-file-format]: ../../dev/foam-file-format.md 'Foam File Format'
|
||||
[note-templates]: note-templates.md 'Note Templates'
|
||||
[link-reference-definition-improvements]: ../../dev/proposals/link-reference-definition-improvements.md 'Link Reference Definition Improvements'
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
- Check the formatting rules for links on [[foam-file-format]] and [[wikilinks]]
|
||||
|
||||
## I don't want Foam enabled for all my workspaces
|
||||
|
||||
Any extension you install in Visual Studio Code is enabled by default. Given the philosophy of Foam, it works out of the box without doing any configuration upfront. In case you want to disable Foam for a specific workspace, or disable Foam by default and enable it for specific workspaces, it is advised to follow the best practices as [documented by Visual Studio Code](https://code.visualstudio.com/docs/editor/extension-marketplace#_manage-extensions)
|
||||
|
||||
## I want to publish the graph view to GitHub pages or Vercel
|
||||
|
||||
If you want a different front-end look to your published foam and a way to see your graph view, we'd recommend checking out these templates:
|
||||
|
||||
- [foam-gatsby](https://github.com/mathieudutour/foam-gatsby-template) by [Mathieu Dutour](https://github.com/mathieudutour)
|
||||
- [foam-gatsby-kb](https://github.com/hikerpig/foam-template-gatsby-kb) by [hikerpig](https://github.com/hikerpig)
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[recommended-extensions]: getting-started/recommended-extensions.md "Recommended Extensions"
|
||||
[foam-file-format]: ../dev/foam-file-format.md "Foam File Format"
|
||||
[wikilinks]: features/wikilinks.md "Wikilinks"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[recommended-extensions]: getting-started/recommended-extensions.md 'Recommended Extensions'
|
||||
[foam-file-format]: ../dev/foam-file-format.md 'Foam File Format'
|
||||
[wikilinks]: features/wikilinks.md 'Wikilinks'
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
# Creating New Notes
|
||||
|
||||
- Write out a new `[[wikilink]]` and `Cmd` + `Click` to create a new file and enter it.
|
||||
- For keyboard navigation, use the 'Follow Definition' key `F12` (or [remap the 'editor.action.revealDefinition' key binding](https://code.visualstudio.com/docs/getstarted/keybindings) to something more ergonomic)
|
||||
- `Cmd` + `Shift` + `P` (`Ctrl` + `Shift` + `P` for Windows), execute `Foam: Create Note` and enter a **Title Case Name** to create `Title Case Name.md`
|
||||
- Add a keyboard binding to make creating new notes easier. See [[commands]] for more info on this.
|
||||
- The [[note-templates]] used by this command can be customized.
|
||||
- You shouldn't worry too much about categorizing your notes. You can always [[search-for-notes]], and explore them using the [[graph-visualization]].
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[commands]: ../features/commands.md "Foam Commands"
|
||||
[note-templates]: ../features/note-templates.md "Note Templates"
|
||||
[search-for-notes]: ../recipes/search-for-notes.md "Search for Notes"
|
||||
[graph-visualization]: ../features/graph-visualization.md "Graph Visualization"
|
||||
[//end]: # "Autogenerated link references"
|
||||
209
docs/user/getting-started/first-workspace.md
Normal file
209
docs/user/getting-started/first-workspace.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# Creating Your First Workspace
|
||||
|
||||
A Foam workspace is where all your notes, ideas, and knowledge live. Think of it as your digital garden where thoughts can grow and connect. This guide will help you set up a workspace that's organized, scalable, and tailored to your thinking style.
|
||||
|
||||
## Understanding Workspaces
|
||||
|
||||
A Foam workspace is simply a folder containing **Markdown files** (`.md`) - your actual notes.
|
||||
|
||||
Optionally it can contain:
|
||||
|
||||
- **Configuration files** - VS Code settings and Foam preferences
|
||||
- **Assets** - images, attachments, and other media
|
||||
- **Templates** - reusable note structures
|
||||
|
||||
### Single vs. Multiple Workspaces
|
||||
|
||||
**Recommended: Single Workspace**
|
||||
|
||||
- Keep all your knowledge in one place
|
||||
- Better link discovery and graph visualization
|
||||
- Easier to maintain and backup
|
||||
- Follows the "unified knowledge base" principle
|
||||
|
||||
**Deprecated: Multiple Workspaces** (deprecated - advanced users only)
|
||||
|
||||
- Separate professional and personal knowledge
|
||||
- Isolate sensitive information
|
||||
- Different workflows for different projects
|
||||
|
||||
Multiple workspaces are to be considered deprecated at this point, and might become unsupported in the future.
|
||||
You can simulate a complex workspace by using file/folder links.
|
||||
|
||||
## Method 1: Using the Foam Template (Recommended)
|
||||
|
||||
The easiest way to start is with our pre-configured template:
|
||||
|
||||
### Step 1: Create from Template
|
||||
|
||||
1. **Visit** [github.com/foambubble/foam-template](https://github.com/foambubble/foam-template)
|
||||
2. **Click "Use this template"** (you'll need a GitHub account)
|
||||
3. **Name your repository** (e.g., "john-knowledge-base", "my-second-brain")
|
||||
4. **Choose visibility:**
|
||||
- **Private** - for personal notes (recommended)
|
||||
- **Public** - if you want to share your knowledge openly
|
||||
|
||||
### Step 2: Clone Locally
|
||||
|
||||
```bash
|
||||
git clone https://github.com/yourusername/your-repo-name.git
|
||||
cd your-repo-name
|
||||
```
|
||||
|
||||
### Step 3: Open in VS Code
|
||||
|
||||
1. **Launch VS Code**
|
||||
2. **File > Open Folder**
|
||||
3. **Select your cloned repository folder**
|
||||
|
||||
## Method 2: Start from Scratch
|
||||
|
||||
For a minimal setup:
|
||||
|
||||
1. **Create a new folder** on your computer
|
||||
2. **Open the folder** in VS Code (`File > Open Folder`)
|
||||
|
||||
That's all, you can start working with your markdown files and Foam will take care of the rest.
|
||||
|
||||
## Ideas for your knowledge base
|
||||
|
||||
### 1. Customize Your Settings
|
||||
|
||||
Review and adjust `.vscode/settings.json` based on your preferences:
|
||||
|
||||
- **Daily notes location** - where your daily notes are stored
|
||||
- **Image handling** - how pasted images are organized
|
||||
- **Link format** - with or without file extensions
|
||||
|
||||
### 2. Set Up Your Inbox
|
||||
|
||||
Create `inbox.md` as your default capture location:
|
||||
|
||||
```markdown
|
||||
# Inbox
|
||||
|
||||
Quick notes and ideas go here before being organized.
|
||||
|
||||
## Today's Captures
|
||||
|
||||
-
|
||||
|
||||
## To Process
|
||||
|
||||
-
|
||||
|
||||
## Ideas
|
||||
|
||||
-
|
||||
```
|
||||
|
||||
### 3. Create Core Structure Notes
|
||||
|
||||
## Workspace Organization Strategies
|
||||
|
||||
Establish your main organizational notes.
|
||||
You can use any methodology, Foam is not opinionated.
|
||||
|
||||
The only recommendation is to get started, you can improve later.
|
||||
|
||||
The two main methods adopted by users are [PARA](https://fortelabs.com/blog/para/) and [Zettelkasten](https://zettelkasten.de/overview/).
|
||||
|
||||
### The PARA Method
|
||||
|
||||
Organize around four categories:
|
||||
|
||||
- **Projects** - Things with deadlines
|
||||
- **Areas** - Ongoing responsibilities
|
||||
- **Resources** - Future reference materials
|
||||
- **Archive** - Inactive items
|
||||
|
||||
### Zettelkasten Approach
|
||||
|
||||
Number-based system for atomic ideas:
|
||||
|
||||
- **Permanent notes** - `202501251030-idea-title.md`
|
||||
- **Literature notes** - `book-author-year.md`
|
||||
- **Index notes** - `index-topic.md`
|
||||
|
||||
### 4. Configure Daily Notes
|
||||
|
||||
Daily notes are perfect for:
|
||||
|
||||
- Daily planning and reflection
|
||||
- Meeting notes
|
||||
- Journal entries
|
||||
- Quick captures
|
||||
|
||||
Test your daily notes setup:
|
||||
|
||||
1. **Press `Ctrl+Shift+P` / `Cmd+Shift+P`**
|
||||
2. **Type "Foam: Open Daily Note"**
|
||||
3. **Verify the note is created in the right location**
|
||||
|
||||
Alternatively you can press `Alt+D` to open today's daily note, or `Alt+H` to open another day's daily note.
|
||||
Use the `.foam/templates/daily-note.md` to customize your daily note.
|
||||
|
||||
## Best Practices for New Workspaces
|
||||
|
||||
### 1. Start Small
|
||||
|
||||
- Begin with just a few notes
|
||||
- Don't over-organize initially
|
||||
- Let structure emerge naturally
|
||||
|
||||
### 2. Use Templates
|
||||
|
||||
- Create templates for common note types
|
||||
- Maintain consistency across similar notes
|
||||
- Save time on repetitive formatting
|
||||
|
||||
### 3. Link Early and Often
|
||||
|
||||
- Use `[[wikilinks]]` liberally
|
||||
- Don't worry about creating "perfect" links
|
||||
- Foam handles broken links gracefully
|
||||
|
||||
### 4. Regular Reviews
|
||||
|
||||
- Weekly workspace cleanup
|
||||
- Archive completed projects
|
||||
- Identify missing connections
|
||||
|
||||
## Syncing and Backup
|
||||
|
||||
Foam works on simple files, you can add whatever backup method you prefer on top of it.
|
||||
|
||||
### Git
|
||||
|
||||
Your workspace is a Git repository:
|
||||
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "Add new notes and ideas"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
You can also use other VS Code extensions to manage the git synching if that's helpful.
|
||||
|
||||
### Alternative Sync Methods
|
||||
|
||||
- **Cloud storage** - Dropbox, OneDrive, Google Drive
|
||||
- **Local backup** - Time Machine, File History
|
||||
- **Manual export** - Regular ZIP backups
|
||||
|
||||
## What's Next?
|
||||
|
||||
With your workspace set up, you're ready to:
|
||||
|
||||
1. **[Learn note-taking fundamentals](note-taking-in-foam.md)** - Master Markdown and writing effective notes
|
||||
2. **[Explore navigation](navigation.md)** - Connect your thoughts with wikilinks
|
||||
3. **[Discover the graph view](../features/graph-view.md)** - Visualize your knowledge network
|
||||
4. **[Set up templates](../features/templates.md)** - Standardize your note creation process
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter setup issues:
|
||||
|
||||
- Check the [Installation Guide](installation.md) for prerequisites
|
||||
- Visit the [FAQ](../faq.md) for common workspace problems
|
||||
- Join the [Foam Community Discord](https://foambubble.github.io/join-discord/w)
|
||||
@@ -1,47 +1,256 @@
|
||||
# Getting started with VS Code
|
||||
# Using Foam with VS Code Features
|
||||
|
||||
VS Code is a powerful text editor, hidden behind a simple interface.
|
||||
Foam builds on Visual Studio Code's powerful editing capabilities, integrating seamlessly with VS Code's native features to create a comprehensive knowledge management experience. This guide explores how to leverage VS Code's built-in functionality alongside Foam.
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
VS Code supports various **keyboard shortcuts**, the most important for us are:
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------- | ---------------------------- |
|
||||
| `cmd+N` | create a new file |
|
||||
| `cmd+S` | save the current file |
|
||||
| `cmd+O` | open a file |
|
||||
| `cmd+P` | use quickpick to open a file |
|
||||
| `cmd+shift+P` | invoke a command (see below) |
|
||||
| Shortcut | Action |
|
||||
| ------------- | ----------------------------- |
|
||||
| `cmd+N` | create a new file |
|
||||
| `cmd+S` | save the current file |
|
||||
| `cmd+O` | open a file |
|
||||
| `cmd+P` | use quickpick to open a file |
|
||||
| `alt+D` | open the daily note for today |
|
||||
| `alt+H` | open the daily note for a day |
|
||||
| `cmd+shift+P` | invoke a command (see below) |
|
||||
|
||||
For more information, see the [VS Code keyboard cheat sheets](https://code.visualstudio.com/docs/getstarted/keybindings#_keyboard-shortcuts-reference), where you can also see how to customize your keybindings.
|
||||
|
||||
## Commands
|
||||
### Commands
|
||||
|
||||
Commands make VS Code extremely powerful.
|
||||
|
||||
To invoke a command, press `cmd+shift+P` and select the command you want to execute.
|
||||
For example, to see the Foam graph:
|
||||
|
||||
- press `cmd+shift+P`
|
||||
- press `cmd+shift+P` to open the command bar
|
||||
- start typing `show graph`
|
||||
- select the `Foam: Show Graph` command
|
||||
|
||||
And watch the magic unfold.
|
||||
|
||||
To see all foam commands, type "foam" in the command bar.
|
||||
For more information on commands, see [commands on the VS Code site](https://code.visualstudio.com/docs/getstarted/userinterface#_command-palette).
|
||||
|
||||
If you want to learn more about VS Code, check out their [website](https://code.visualstudio.com/docs#first-steps).
|
||||
|
||||
## Panels
|
||||
### Panels
|
||||
|
||||
You can see a few panels on the left, including:
|
||||
Foam integrates with VS Code panels to provide insights into individual notes and the whole knowledge base.
|
||||
|
||||
- `Outline`: this panel shows you the structure of the file based on the headings
|
||||
- `Tag Explorer`: This shows you the tags in your workspace, see [[tags]] for more information on tags
|
||||
- **`Foam: links`**: Shows all notes that link to and from the currently active note, helping you understand connections and navigate your knowledge graph
|
||||
- **`Foam: Orphaned Notes`**: Displays notes that have no incoming or outgoing links, helping you identify isolated content that might need better integration
|
||||
- **`Tag Explorer`**: Shows all tags used across your workspace in a hierarchical view, see [[tags]] for more information on tags
|
||||
- **`Foam: Graph`**: Visual representation of your note connections (also available as a separate graph view)
|
||||
|
||||
### Styling and Themes
|
||||
|
||||
VS Code is very configurable when it comes to themes and style. Find your ideal set up by running the command `Color Theme`.
|
||||
For more information see the [VS Code documentation](https://code.visualstudio.com/docs/configure/themes).
|
||||
|
||||
### Multi-Cursor Editing
|
||||
|
||||
Edit multiple locations simultaneously for efficient note management:
|
||||
|
||||
**Basic Multi-Cursor:**
|
||||
|
||||
- `Alt+Click` / `Option+Click` - Add cursor at click location
|
||||
- `Ctrl+Alt+Down` / `Cmd+Option+Down` - Add cursor below
|
||||
- `Ctrl+Alt+Up` / `Cmd+Option+Up` - Add cursor above
|
||||
- `Ctrl+D` / `Cmd+D` - Select next occurrence of word
|
||||
- `Ctrl+Shift+L` / `Cmd+Shift+L` - Select all occurrences
|
||||
|
||||
**Bulk wikilink creation:**
|
||||
|
||||
1. **Select a word** (e.g., "Python")
|
||||
2. **Press `Ctrl+Shift+L`** to select all occurrences
|
||||
3. **Type `[[]]`** to wrap all instances
|
||||
4. **Arrow key** to position cursor inside brackets
|
||||
|
||||
### Find and Replace
|
||||
|
||||
Powerful search and replace for note maintenance:
|
||||
|
||||
**Basic Find/Replace:**
|
||||
|
||||
- `Ctrl+F` / `Cmd+F` - Find in current file
|
||||
- `Ctrl+H` / `Cmd+H` - Replace in current file
|
||||
- `Ctrl+Shift+F` / `Cmd+Shift+F` - Find across workspace
|
||||
- `Ctrl+Shift+H` / `Cmd+Shift+H` - Replace across workspace
|
||||
|
||||
### Text Folding
|
||||
|
||||
Organize long notes with collapsible sections:
|
||||
|
||||
**Folding Controls:**
|
||||
|
||||
- **Click fold icons** in the gutter next to headings
|
||||
- `Ctrl+Shift+[` / `Cmd+Option+[` - Fold current section
|
||||
- `Ctrl+Shift+]` / `Cmd+Option+]` - Unfold current section
|
||||
- `Ctrl+K Ctrl+0` / `Cmd+K Cmd+0` - Fold all
|
||||
- `Ctrl+K Ctrl+J` / `Cmd+K Cmd+J` - Unfold all
|
||||
|
||||
## File Management
|
||||
|
||||
### Explorer Integration
|
||||
|
||||
Leverage VS Code's file explorer for note organization:
|
||||
|
||||
**File Operations:**
|
||||
|
||||
- **Drag and drop** files to reorganize
|
||||
- **Right-click context menus** for quick actions
|
||||
- **New file/folder** creation with shortcuts
|
||||
- **Bulk selection** with Ctrl+Click / Cmd+Click
|
||||
|
||||
**Quick File Actions:**
|
||||
|
||||
- `F2` - Rename file (Foam updates links automatically)
|
||||
- `Delete` - Move to trash
|
||||
- `Ctrl+C` / `Cmd+C` then `Ctrl+V` / `Cmd+V` - Copy/paste files
|
||||
- **Right-click → Reveal in Explorer/Finder** - Open in file system
|
||||
|
||||
### Quick Open
|
||||
|
||||
Rapid file navigation for large knowledge bases:
|
||||
|
||||
**Quick Open Commands:**
|
||||
|
||||
- `Ctrl+P` / `Cmd+P` - Go to file
|
||||
- `Ctrl+Shift+O` / `Cmd+Shift+O` - Go to symbol (headings in Markdown)
|
||||
- `Ctrl+T` / `Cmd+T` - Go to symbol in workspace
|
||||
- `Ctrl+G` / `Cmd+G` - Go to line number
|
||||
|
||||
**Search Patterns:**
|
||||
|
||||
```
|
||||
# Go to File (Ctrl+P)
|
||||
machine # Finds "machine-learning.md"
|
||||
proj alpha # Finds "project-alpha.md"
|
||||
daily/2025 # Finds files in daily/2025 folder
|
||||
|
||||
# Go to Symbol (Ctrl+Shift+O)
|
||||
@introduction # Jump to "Introduction" heading
|
||||
@#setup # Jump to "Setup" heading
|
||||
:50 # Go to line 50
|
||||
```
|
||||
|
||||
## Search and Discovery
|
||||
|
||||
### Global Search
|
||||
|
||||
Find content across your entire knowledge base:
|
||||
|
||||
**Search Interface (`Ctrl+Shift+F` / `Cmd+Shift+F`):**
|
||||
|
||||
- **Search box** - Enter your query
|
||||
- **Replace box** - Toggle with replace arrow
|
||||
- **Include/Exclude patterns** - Filter by file types or folders
|
||||
- **Match case** - Case-sensitive search
|
||||
- **Match whole word** - Exact word matching
|
||||
- **Use regular expression** - Advanced pattern matching
|
||||
|
||||
### Timeline View
|
||||
|
||||
Track changes to your notes over time:
|
||||
|
||||
**Accessing Timeline:**
|
||||
|
||||
1. **Open Explorer panel**
|
||||
2. **Expand "Timeline" section** at bottom
|
||||
3. **Select a file** to see its change history
|
||||
4. **Click timeline entries** to see diff views
|
||||
|
||||
**Timeline Features:**
|
||||
|
||||
- **Git commits** show when notes were changed
|
||||
- **File saves** track editing sessions
|
||||
- **Diff views** highlight what changed
|
||||
- **Restore points** for recovering previous versions
|
||||
|
||||
### Outline View
|
||||
|
||||
Navigate long notes with hierarchical structure:
|
||||
|
||||
**Outline Panel:**
|
||||
|
||||
1. **Enable in Explorer** (expand "Outline" section)
|
||||
2. **Shows heading hierarchy** for current note
|
||||
3. **Click headings** to jump to sections
|
||||
4. **Collapse/expand** sections in outline
|
||||
|
||||
## Version Control Integration
|
||||
|
||||
### Git Integration
|
||||
|
||||
Track changes to your knowledge base:
|
||||
|
||||
**Source Control Panel:**
|
||||
|
||||
- **View changes** - See modified files
|
||||
- **Stage changes** - Click `+` to stage files
|
||||
- **Commit changes** - Enter message and commit
|
||||
- **Sync changes** - Push/pull from remote
|
||||
|
||||
**Git Workflow for Notes:**
|
||||
|
||||
1. **Write and edit** your notes
|
||||
2. **Review changes** in Source Control panel
|
||||
3. **Stage relevant files** for commit
|
||||
4. **Write meaningful commit message**
|
||||
5. **Commit and push** to backup/share
|
||||
|
||||
**Useful Git Features:**
|
||||
|
||||
- **Diff view** - See exactly what changed
|
||||
- **File history** - Track note evolution over time
|
||||
- **Branch management** - Experiment with different organization approaches
|
||||
- **Merge conflicts** - Resolve when collaborating
|
||||
|
||||
## Markdown Features
|
||||
|
||||
### Preview Integration
|
||||
|
||||
View formatted notes alongside editing:
|
||||
|
||||
**Preview Commands:**
|
||||
|
||||
- `Ctrl+Shift+V` / `Cmd+Shift+V` - Open preview
|
||||
- `Ctrl+K V` / `Cmd+K V` - Open preview to side
|
||||
- **Preview lock** - Pin preview to specific file
|
||||
|
||||
**Diagrams (with Mermaid extension):**
|
||||
|
||||
````markdown
|
||||
```mermaid
|
||||
graph TD
|
||||
A[Foam Workspace] --> B[Notes]
|
||||
A --> C[Templates]
|
||||
A --> D[Assets]
|
||||
B --> E[Wikilinks]
|
||||
B --> F[Tags]
|
||||
E --> G[Graph View]
|
||||
```
|
||||
````
|
||||
|
||||
## Extension Ecosystem
|
||||
|
||||
Extend Foam's capabilities with complementary extensions.
|
||||
Look for them in the [VS Code Marketplace](https://marketplace.visualstudio.com/).
|
||||
|
||||
## What's Next?
|
||||
|
||||
With VS Code mastery, explore advanced Foam topics:
|
||||
|
||||
1. **[[recommended-extensions]]** - See complementary extensions to improve your note taking experience
|
||||
2. **[[publishing]]** - Share your knowledge base
|
||||
|
||||
## Settings
|
||||
|
||||
To view or change the settings in VS Code, press `cmd+,` on Mac and `ctrl+,` on Windows/Linux.
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[tags]: ../features/tags.md "Tags"
|
||||
[//end]: # "Autogenerated link references"
|
||||
[recommended-extensions]: recommended-extensions.md "Recommended Extensions"
|
||||
[publishing]: ../publishing/publishing.md "Publishing pages"
|
||||
[//end]: # "Autogenerated link references"
|
||||
|
||||
79
docs/user/getting-started/installation.md
Normal file
79
docs/user/getting-started/installation.md
Normal file
@@ -0,0 +1,79 @@
|
||||
# Installation
|
||||
|
||||
Getting started with Foam is straightforward. This guide will walk you through installing everything you need to begin your knowledge management journey.
|
||||
|
||||
## Step 1: Install Visual Studio Code
|
||||
|
||||
Foam is built on VS Code, Microsoft's free, open-source code editor. You can download it at https://code.visualstudio.com/
|
||||
|
||||
### Why VS Code?
|
||||
|
||||
VS Code provides:
|
||||
|
||||
- Excellent Markdown editing capabilities
|
||||
- Rich extension ecosystem
|
||||
- Cross-platform compatibility
|
||||
- Integrated terminal and Git support
|
||||
- Customizable interface and shortcuts
|
||||
|
||||
To learn more about using VS Code with Foam, check [[get-started-with-vscode]]
|
||||
|
||||
## Step 2: Install the Foam Extension
|
||||
|
||||
The Foam extension adds knowledge management superpowers to VS Code.
|
||||
|
||||
1. **Open VS Code**
|
||||
2. **Click the Extensions icon** in the sidebar (or press `Ctrl+Shift+X` / `Cmd+Shift+X`)
|
||||
3. **Search for "Foam"** in the extensions marketplace
|
||||
4. **Click "Install"** on the official Foam extension by Foam Team
|
||||
5. **Reload VS Code** when prompted
|
||||
|
||||
### What the Foam Extension Provides
|
||||
|
||||
- Wikilink auto-completion and navigation
|
||||
- Backlink discovery and panel
|
||||
- Graph visualization
|
||||
- Powerful note template engine
|
||||
- Daily notes functionality
|
||||
|
||||
## Step 3: Install Recommended Extensions
|
||||
|
||||
While Foam works on its own, it is focused on the networking aspect of your notes. You might want to install additional extensions to improve the editing experience or the functionality of your notes.
|
||||
|
||||
### Useful Extensions
|
||||
|
||||
- **Markdown All in One** - Rich Markdown editing features. Highly recommended.
|
||||
|
||||
Other extensions:
|
||||
|
||||
- **Spell Right** - Spell checking for your notes
|
||||
- **Paste Image** - Easily insert images from clipboard
|
||||
- **Todo Tree** - Track TODO items across your workspace
|
||||
|
||||
## What's Next?
|
||||
|
||||
Now that Foam is installed, you're ready to:
|
||||
|
||||
1. **[[first-workspace]]** - Set up your knowledge base structure
|
||||
2. **[[get-started-with-vscode]]** - Learn how to use VS Code for note taking
|
||||
3. **[[note-taking-in-foam]]** - Write your first Markdown notes
|
||||
4. **[[navigation]]** - Connect your thoughts with wikilinks
|
||||
5. **[[graph-view]]** - Visualize your knowledge network
|
||||
|
||||
## Getting Help
|
||||
|
||||
If you encounter issues:
|
||||
|
||||
- Check the [[frequently-asked-questions]] for common problems
|
||||
- Visit the [Foam Community Discord](https://foambubble.github.io/join-discord/w)
|
||||
- Browse [GitHub Issues](https://github.com/foambubble/foam/issues) for known problems
|
||||
- Ask questions in [GitHub Discussions](https://github.com/foambubble/foam/discussions)
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[get-started-with-vscode]: get-started-with-vscode.md "Using Foam with VS Code Features"
|
||||
[first-workspace]: first-workspace.md "Creating Your First Workspace"
|
||||
[note-taking-in-foam]: note-taking-in-foam.md "Note-Taking in Foam"
|
||||
[navigation]: navigation.md "Navigation in Foam"
|
||||
[graph-view]: ../features/graph-view.md "Graph Visualization"
|
||||
[frequently-asked-questions]: ../frequently-asked-questions.md "Frequently Asked Questions"
|
||||
[//end]: # "Autogenerated link references"
|
||||
139
docs/user/getting-started/navigation.md
Normal file
139
docs/user/getting-started/navigation.md
Normal file
@@ -0,0 +1,139 @@
|
||||
# Navigation in Foam
|
||||
|
||||
Navigation is where Foam truly shines. Unlike traditional file systems or notebooks, Foam lets you move through your knowledge by following connections between ideas. This guide will teach you how to navigate efficiently using wikilinks, backlinks, and other powerful features.
|
||||
|
||||
_[📹 Watch: Mastering navigation in Foam]_
|
||||
|
||||
## Understanding Wikilinks
|
||||
|
||||
Wikilinks are the backbone of Foam navigation. They connect your thoughts and let you jump between related concepts instantly.
|
||||
|
||||
### Basic Wikilink Syntax
|
||||
|
||||
```markdown
|
||||
I'm learning about [[Machine Learning]] and its applications in [[Data Science]].
|
||||
|
||||
This reminds me of my notes on [[Python Programming]] from yesterday.
|
||||
```
|
||||
|
||||
When you type `[[`, Foam shows you a list of existing notes to link to. If the note doesn't exist, Foam creates a placeholder that you can click to create the note later.
|
||||
|
||||
### Wikilink Variations
|
||||
|
||||
**Link to a specific heading:**
|
||||
|
||||
```markdown
|
||||
See the [[Project Management#Risk Assessment]] section for details.
|
||||
```
|
||||
|
||||
**Link to a specific block:**
|
||||
|
||||
```markdown
|
||||
See the [[Project Management#^block-id]] paragraph for details.
|
||||
```
|
||||
|
||||
**Link with alias:**
|
||||
|
||||
```markdown
|
||||
According to [[Einstein, Albert|Einstein]], imagination is more important than knowledge.
|
||||
```
|
||||
|
||||
### Autocomplete and Link Assistance
|
||||
|
||||
Foam provides intelligent autocomplete when creating links:
|
||||
|
||||
1. **Type `[[`** - Foam shows a dropdown of existing notes
|
||||
2. **Start typing** - The list filters to matching notes
|
||||
3. **Use arrow keys** to navigate suggestions
|
||||
4. **Press Enter** to insert the selected link
|
||||
|
||||
## The Foam Graph
|
||||
|
||||
For a visual overview of your knowledge base, Foam offers a [[graph-view]]. This feature renders your notes as nodes and the links between them as connections, creating an interactive map of your thoughts.
|
||||
|
||||
_[📹 Watch: Navigation with the Foam Graph]_
|
||||
|
||||
### Using the Graph
|
||||
|
||||
1. **Open the Command Palette** (`Ctrl+Shift+P` / `Cmd+Shift+P`)
|
||||
2. **Run the "Foam: Show Graph" command**
|
||||
3. The graph will open in a new panel. You can:
|
||||
|
||||
- **Click on a node** to navigate to that note.
|
||||
- **Pan and zoom** to explore different areas of your knowledge base.
|
||||
- **See how ideas cluster** and identify central concepts.
|
||||
|
||||
## Backlinks: The Power of Reverse Navigation
|
||||
|
||||
Backlinks show you which notes reference the current note. This creates a web of knowledge where ideas naturally connect.
|
||||
|
||||
### Viewing Backlinks
|
||||
|
||||
1. **Open any note**
|
||||
2. **Look for the "Connections" panel** in the sidebar
|
||||
3. **See all notes that link to your current note**
|
||||
4. **Click any backlink** to jump to that note
|
||||
|
||||
## Quick Navigation Features
|
||||
|
||||
### Command Palette Navigation
|
||||
|
||||
Press `Ctrl+Shift+P` / `Cmd+Shift+P` and try these commands:
|
||||
|
||||
- **"Foam: Open Random Note"** - Discover forgotten knowledge
|
||||
- **"Foam: Open Daily Note"** - Quick access to today's notes
|
||||
- **"Go to File"** (`Ctrl+P` / `Cmd+P`) - Fast file opening
|
||||
- **"Go to Symbol"** (`Ctrl+Shift+O` / `Cmd+Shift+O`) - Jump to headings within a note
|
||||
|
||||
### File Explorer Integration
|
||||
|
||||
The VS Code file explorer shows your note structure:
|
||||
|
||||
- **Click any `.md` file** to open it
|
||||
- **Use the search box** to filter files
|
||||
- **Right-click** for context menus (rename, delete, etc.)
|
||||
|
||||
Foam also supports the Note Explorer, which is like the file explorer, but centered around the Foam metadata.
|
||||
|
||||
### Quick Open
|
||||
|
||||
Press `Ctrl+P` / `Cmd+P` and start typing:
|
||||
|
||||
- **File names** - `machine` finds "machine-learning.md"
|
||||
- **Partial paths** - `daily/2025` finds daily notes from 2025
|
||||
- **Recent files** - Empty search shows recently opened files
|
||||
|
||||
## Link Management and Maintenance
|
||||
|
||||
### Finding Broken Links - Placeholders
|
||||
|
||||
In Foam broken links are considered placeholders for future notes.
|
||||
Placeholders (references to non-existent notes) appear differently:
|
||||
|
||||
- In editor: `[[missing-note]]` will be highlighted a different color
|
||||
- In preview: Shows as regular text or with special styling
|
||||
|
||||
Clicking on a placeholder in the editor will create the corresponding note.
|
||||
|
||||
**To find all placeholders:**
|
||||
|
||||
You can find placeholders by looking at the `Placeholders` treeview.
|
||||
|
||||
### Renaming and Moving Notes
|
||||
|
||||
When you rename a note file:
|
||||
|
||||
1. **Use VS Code's rename function** (`F2` key)
|
||||
2. **Foam automatically updates** all links to that note
|
||||
3. **Check the "Problems" panel** for any issues
|
||||
|
||||
Currently you cannot rename whole folders.
|
||||
|
||||
## What's Next?
|
||||
|
||||
With navigation mastered, you're ready to:
|
||||
|
||||
1. **[Explore the graph view](../features/graph-view.md)** - Visualize your knowledge network
|
||||
2. **[Learn about backlinks](../features/backlinking.md)** - Master bidirectional linking
|
||||
3. **[Set up templates](../features/templates.md)** - Standardize your note creation
|
||||
4. **[Use tags effectively](../features/tags.md)** - Add another layer of organization
|
||||
238
docs/user/getting-started/note-taking-in-foam.md
Normal file
238
docs/user/getting-started/note-taking-in-foam.md
Normal file
@@ -0,0 +1,238 @@
|
||||
# Note-Taking in Foam
|
||||
|
||||
Effective note-taking is the foundation of any knowledge management system. In Foam, you'll write notes in Markdown, a simple and powerful format that's both human-readable and widely supported. This guide will teach you everything you need to know about writing great notes in Foam.
|
||||
|
||||
## Markdown Basics
|
||||
|
||||
Markdown is a lightweight markup language that uses simple syntax to format text. Here are the essentials:
|
||||
|
||||
### Headings
|
||||
|
||||
```markdown
|
||||
# Heading 1 (Main Title)
|
||||
|
||||
## Heading 2 (Major Section)
|
||||
|
||||
### Heading 3 (Subsection)
|
||||
|
||||
#### Heading 4 (Minor Section)
|
||||
```
|
||||
|
||||
### Text Formatting
|
||||
|
||||
```markdown
|
||||
**Bold text**
|
||||
_Italic text_
|
||||
**_Bold and italic_**
|
||||
~~Strikethrough~~
|
||||
`Inline code`
|
||||
```
|
||||
|
||||
### Lists
|
||||
|
||||
```markdown
|
||||
## Unordered Lists
|
||||
|
||||
- First item
|
||||
- Second item
|
||||
- Nested item
|
||||
- Another nested item
|
||||
|
||||
## Ordered Lists
|
||||
|
||||
1. First step
|
||||
2. Second step
|
||||
1. Sub-step
|
||||
2. Another sub-step
|
||||
```
|
||||
|
||||
### Links and Images
|
||||
|
||||
```markdown
|
||||
[External link](https://example.com)
|
||||

|
||||
```
|
||||
|
||||
### Code Blocks
|
||||
|
||||
````markdown
|
||||
```javascript
|
||||
function greet(name) {
|
||||
return `Hello, ${name}!`;
|
||||
}
|
||||
```
|
||||
````
|
||||
|
||||
### Tables
|
||||
|
||||
```markdown
|
||||
| Column 1 | Column 2 | Column 3 |
|
||||
| -------- | -------- | -------- |
|
||||
| Data 1 | Data 2 | Data 3 |
|
||||
| Data 4 | Data 5 | Data 6 |
|
||||
```
|
||||
|
||||
### Quotes and Dividers
|
||||
|
||||
```markdown
|
||||
> This is a quote or important note
|
||||
> It can span multiple lines
|
||||
|
||||
---
|
||||
|
||||
Use three dashes for horizontal dividers
|
||||
```
|
||||
|
||||
_[📹 Watch: Markdown syntax essentials for note-taking]_
|
||||
|
||||
## Foam-Specific Features
|
||||
|
||||
Beyond standard Markdown, Foam adds several powerful features:
|
||||
|
||||
### Wikilinks
|
||||
|
||||
Connect your notes with double brackets:
|
||||
|
||||
```markdown
|
||||
I'm reading about [[Project Management]] and its relationship to [[Personal Productivity]].
|
||||
|
||||
This connects to [[2025-01-25-daily-note]] where I first had this insight.
|
||||
```
|
||||
|
||||
### Note Embedding
|
||||
|
||||
Include content from other notes via [[embeds]]:
|
||||
|
||||
```markdown
|
||||
![[Project Management#Key Principles]]
|
||||
|
||||
This embeds the "Key Principles" section from the Project Management note.
|
||||
```
|
||||
|
||||
### Tags
|
||||
|
||||
Organize your content with [[tags]]:
|
||||
|
||||
```markdown
|
||||
#productivity #learning #foam
|
||||
|
||||
Tags can be anywhere in your note and help with organization and filtering.
|
||||
```
|
||||
|
||||
Use nested tags for better organization:
|
||||
|
||||
```markdown
|
||||
#work/projects/website
|
||||
#learning/programming/javascript
|
||||
#personal/health/exercise
|
||||
```
|
||||
|
||||
Those tags will show as a tree structure in the [Tag Explorer](../features/tags.md)
|
||||
|
||||
### Note Properties (YAML Front Matter)
|
||||
|
||||
Add metadata to your notes:
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: 'Advanced Note-Taking Strategies'
|
||||
tags: [productivity, learning, methods]
|
||||
created: 2025-01-25
|
||||
modified: 2025-01-25
|
||||
status: draft
|
||||
---
|
||||
|
||||
# Advanced Note-Taking Strategies
|
||||
|
||||
Your note content goes here...
|
||||
```
|
||||
|
||||
## Writing Effective Notes
|
||||
|
||||
### The Atomic Principle
|
||||
|
||||
Each note should focus on one concept or idea:
|
||||
|
||||
**Good Example:**
|
||||
|
||||
```markdown
|
||||
# The Feynman Technique
|
||||
|
||||
A learning method where you explain a concept in simple terms as if teaching it to someone else.
|
||||
|
||||
## Steps
|
||||
|
||||
1. Choose a topic to learn
|
||||
2. Explain it in simple terms
|
||||
3. Identify gaps in understanding
|
||||
4. Simplify and use analogies
|
||||
|
||||
## Why It Works
|
||||
|
||||
- Forces active engagement with material
|
||||
- Reveals knowledge gaps quickly
|
||||
- Improves retention through teaching
|
||||
|
||||
Related: [[Active Learning]] [[Study Methods]]
|
||||
```
|
||||
|
||||
**Avoid:**
|
||||
Mixing multiple unrelated concepts in one note.
|
||||
|
||||
### Use Descriptive Titles
|
||||
|
||||
Your note titles should clearly indicate the content:
|
||||
|
||||
**Good:** `REST API Design Principles`
|
||||
**Good:** `Meeting Notes - Product Roadmap Review 2025-01-25`
|
||||
**Avoid:** `Stuff I Learned Today`
|
||||
**Avoid:** `Notes`
|
||||
|
||||
### Link Generously
|
||||
|
||||
Don't hesitate to create links, even to notes that don't exist yet:
|
||||
|
||||
```markdown
|
||||
# Machine Learning Fundamentals
|
||||
|
||||
Machine learning is a subset of [[Artificial Intelligence]] that focuses on creating algorithms that can learn from [[Data]].
|
||||
|
||||
Key concepts include:
|
||||
|
||||
- [[Supervised Learning]]
|
||||
- [[Unsupervised Learning]]
|
||||
- [[Neural Networks]]
|
||||
- [[Feature Engineering]]
|
||||
|
||||
This connects to my work on [[Customer Behavior Analysis]] and [[Predictive Analytics]].
|
||||
```
|
||||
|
||||
Foam will create placeholder pages for missing notes, making it easy to fill in knowledge gaps later.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
Essential VS Code shortcuts for note-taking:
|
||||
|
||||
| Shortcut | Action |
|
||||
| ------------------------------ | --------------------- |
|
||||
| `Ctrl+N` / `Cmd+N` | New file |
|
||||
| `Ctrl+S` / `Cmd+S` | Save file |
|
||||
| `Ctrl+P` / `Cmd+P` | Quick file open |
|
||||
| `Ctrl+Shift+P` / `Cmd+Shift+P` | Command palette |
|
||||
| `Ctrl+K V` / `Cmd+K V` | Open Markdown preview |
|
||||
| `Ctrl+[` / `Cmd+[` | Decrease indent |
|
||||
| `Ctrl+]` / `Cmd+]` | Increase indent |
|
||||
| `Alt+Z` / `Option+Z` | Toggle word wrap |
|
||||
|
||||
## What's Next?
|
||||
|
||||
Now that you understand note-taking basics:
|
||||
|
||||
1. **[[navigation]]** - Learn to move efficiently between notes with wikilinks
|
||||
2. **[Explore the graph view](../features/graph-view.md)** - Visualize the connections in your knowledge base
|
||||
3. **[Set up templates](../features/templates.md)** - Create reusable note structures
|
||||
4. **[Use daily notes](../features/daily-notes.md)** - Establish a daily capture routine
|
||||
|
||||
[navigation]: navigation.md 'Navigation in Foam'
|
||||
[tags]: ../features/tags.md 'Tags'
|
||||
|
||||
@@ -4,7 +4,7 @@ These extensions defined in `.vscode/extensions.json` are automatically installe
|
||||
|
||||
This list is subject to change.
|
||||
|
||||
- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode) (alpha)
|
||||
- [Foam for VSCode](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
- [Markdown All In One](https://marketplace.visualstudio.com/items?itemName=yzhang.markdown-all-in-one)
|
||||
- [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)
|
||||
|
||||
@@ -12,14 +12,13 @@ This list is subject to change.
|
||||
|
||||
These extensions are not defined in `.vscode/extensions.json`, but have been used by others and shown to play nice with Foam.
|
||||
|
||||
- [Emojisense](https://marketplace.visualstudio.com/items?itemName=bierner.emojisense)
|
||||
- [Markdown Emoji](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-emoji) (adds `:smile:` syntax, works with emojisense to provide autocomplete for this syntax)
|
||||
- [Markdown Preview Mermaid Support](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-mermaid)
|
||||
- [Mermaid Markdown Syntax Highlighting](https://marketplace.visualstudio.com/items?itemName=bpruitt-goddard.mermaid-markdown-syntax-highlighting)
|
||||
- [Excalidraw whiteboard and sketching tool integration](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor)
|
||||
- [VSCode PDF Viewing](https://marketplace.visualstudio.com/items?itemName=tomoki1207.pdf)
|
||||
- [Project Manager](https://marketplace.visualstudio.com/items?itemName=alefragnani.project-manager) (to quickly switch between projects)
|
||||
- [Markdown Extended](https://marketplace.visualstudio.com/items?itemName=jebbs.markdown-extended) (with `kbd` option disabled, `kbd` turns wikilinks into non-clickable buttons)
|
||||
- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc) (easy version management via git auto commits)
|
||||
- [Markdown Footnotes](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-footnotes) (Adds [^footnote] syntax support to VS Code's built-in markdown preview)
|
||||
- [Todo Tree](https://marketplace.visualstudio.com/items?itemName=Gruntfuggly.todo-tree) (Searches workspace for TODO and related comments and summarizes those lines in vs-code gutter)
|
||||
- [Emojisense](https://marketplace.visualstudio.com/items?itemName=bierner.emojisense) (provides emoji autocomplete and suggestions in markdown files)
|
||||
- [Markdown Emoji](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-emoji) (adds `:smile:` syntax support, works with emojisense to provide autocomplete for this syntax)
|
||||
- [Mermaid diagrams Support](https://marketplace.visualstudio.com/items?itemName=MermaidChart.vscode-mermaid-chart) (adds syntax highlighting for Mermaid code blocks in markdown and renders Mermaid diagrams in markdown preview)
|
||||
- [Excalidraw whiteboard and sketching tool integration](https://marketplace.visualstudio.com/items?itemName=pomdtr.excalidraw-editor) (create and edit hand-drawn style diagrams and sketches directly in VS Code)
|
||||
- [VSCode PDF Viewing](https://marketplace.visualstudio.com/items?itemName=tomoki1207.pdf) (view PDF files directly within VS Code without external applications)
|
||||
- [Project Manager](https://marketplace.visualstudio.com/items?itemName=alefragnani.project-manager) (easily switch between multiple projects and workspaces)
|
||||
- [Markdown Extended](https://marketplace.visualstudio.com/items?itemName=jebbs.markdown-extended) (extended markdown syntax support with additional formatting options - use with `kbd` option disabled as it conflicts with wikilinks)
|
||||
- [GitDoc](https://marketplace.visualstudio.com/items?itemName=vsls-contrib.gitdoc) (automatic git commits for easy version management and backup of your notes)
|
||||
- [Markdown Footnotes](https://marketplace.visualstudio.com/items?itemName=bierner.markdown-footnotes) (adds footnote syntax support `[^footnote]` to VS Code's built-in markdown preview)
|
||||
- [Todo Tree](https://marketplace.visualstudio.com/items?itemName=Gruntfuggly.todo-tree) (scans workspace for TODO, FIXME, and other comment tags, displaying them in a tree view and editor gutter)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Sync notes with source control
|
||||
|
||||
Source control is a way to precicely manage the history and content of a directory of files.
|
||||
Source control is a way to precisely manage the history and content of a directory of files.
|
||||
Often used for program code, this feature is very useful for note taking as well.
|
||||
|
||||
There are (too) many ways to commit your changes to source control:
|
||||
@@ -1,74 +0,0 @@
|
||||
# Writing Notes
|
||||
|
||||
Notes are simple text files with some extra flavor, in the shape of Markdown syntax and support for extra properties (see [[note-properties]]).
|
||||
|
||||
## Foam Syntax
|
||||
|
||||
Foam uses standard Markdown, with a few added twists:
|
||||
|
||||
- the title of a note (e.g. in the [[graph-visualization]]) is given by precedence based on:
|
||||
- the `title` property (see [[note-properties]])
|
||||
- the first `# heading 1` of the file
|
||||
- the file name
|
||||
|
||||
## Markdown Syntax
|
||||
|
||||
With Markdown, we can style our notes in a simple way, while keeping the document a simple text file (the best way to future-proof your writings!).
|
||||
|
||||
You can see the formatted output by running the `Markdown: Open Preview to the Side` command.
|
||||
|
||||
Here is a high level overview of Markdown, for more information on the Markdown syntax [see here](https://commonmark.org/help/).
|
||||
|
||||
# Heading 1
|
||||
|
||||
## Heading 2
|
||||
|
||||
### Heading 3
|
||||
|
||||
#### Heading 4
|
||||
|
||||
##### Heading 5
|
||||
|
||||
###### Heading 6
|
||||
|
||||
This is a [link to google](https://www.google.com).
|
||||
|
||||
This is a wikilink (aka internal link) to [[note-properties]].
|
||||
|
||||
Here is an image:
|
||||

|
||||
|
||||
> this is a blockquote
|
||||
> it can span multiple lines
|
||||
|
||||
- list item
|
||||
- list item
|
||||
- list item
|
||||
|
||||
1. One
|
||||
2. Two
|
||||
3. Three
|
||||
|
||||
This text is **in bold** and this is *italic*.
|
||||
|
||||
The following is a horizontal rule
|
||||
|
||||
---
|
||||
|
||||
This is a table:
|
||||
| Column 1 | Column 2 |
|
||||
| -------- | -------- |
|
||||
| R1C1 | R1C2 |
|
||||
| R2C1 | R2C2 |
|
||||
|
||||
You can `inline code` or
|
||||
|
||||
```text
|
||||
you can create
|
||||
code blocks
|
||||
```
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[note-properties]: ../features/note-properties.md "Note Properties"
|
||||
[graph-visualization]: ../features/graph-visualization.md "Graph Visualization"
|
||||
[//end]: # "Autogenerated link references"
|
||||
@@ -1,33 +1,49 @@
|
||||
# Using Foam
|
||||
|
||||
Foam is a collection VS Code extensions and recipes that power up the editor
|
||||
into a full-blown note taking system. This folder contains user documentation
|
||||
describing how to get started using Foam, what its main features are, and
|
||||
strategies for getting the most out of Foam. The full docs are included in the
|
||||
`foam-template` repo that most users start from.
|
||||
Foam is a personal knowledge management system built on [Visual Studio Code](https://code.visualstudio.com/) and [GitHub](https://github.com/). It helps you organize research, create discoverable notes, and publish your knowledge.
|
||||
|
||||
> See also [[frequently-asked-questions]].
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Wikilinks** - Connect thoughts with `[[double bracket]]` syntax
|
||||
- **Embeds** - Include content from other notes with `![[note]]` syntax
|
||||
- **Backlinks** - Automatically discover connections between notes
|
||||
- **Graph visualization** - See your knowledge network visually
|
||||
- **Daily notes** - Capture timestamped thoughts
|
||||
- **Templates** - Standardize note creation
|
||||
- **Tags** - Organize and filter content
|
||||
|
||||
## Why Choose Foam?
|
||||
|
||||
- **Free and open source** - No subscriptions or vendor lock-in
|
||||
- **Own your data** - Notes stored as standard Markdown files
|
||||
- **VS Code integration** - Leverage powerful editing and extensions
|
||||
- **Git-based** - Version control and collaboration built-in
|
||||
|
||||
Foam is like a bathtub: _What you get out of it depends on what you put into it._
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [[installation]]
|
||||
- [[get-started-with-vscode]]
|
||||
- [[recommended-extensions]]
|
||||
- [[creating-new-notes]]
|
||||
- [[write-notes-in-foam]]
|
||||
- [[sync-notes-with-source-control]]
|
||||
- [[first-workspace]]
|
||||
- [[note-taking-in-foam]]
|
||||
- [[sync-notes]]
|
||||
- [[keyboard-shortcuts]]
|
||||
|
||||
## Features
|
||||
|
||||
- [[wikilinks]]
|
||||
- [[embeds]]
|
||||
- [[tags]]
|
||||
- [[backlinking]]
|
||||
- [[daily-notes]]
|
||||
- [[including-notes]]
|
||||
- [[spell-checking]]
|
||||
- [[graph-visualization]]
|
||||
- [[graph-view]]
|
||||
- [[note-properties]]
|
||||
- [[note-templates]]
|
||||
- [[templates]]
|
||||
- [[paste-images-from-clipboard]]
|
||||
- [[custom-markdown-preview-styles]]
|
||||
- [[link-reference-definitions]]
|
||||
@@ -53,21 +69,22 @@ See [[publishing]] for more details.
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[frequently-asked-questions]: frequently-asked-questions.md "Frequently Asked Questions"
|
||||
[get-started-with-vscode]: getting-started/get-started-with-vscode.md "Getting started with VS Code"
|
||||
[installation]: getting-started/installation.md "Installation"
|
||||
[get-started-with-vscode]: getting-started/get-started-with-vscode.md "Using Foam with VS Code Features"
|
||||
[recommended-extensions]: getting-started/recommended-extensions.md "Recommended Extensions"
|
||||
[creating-new-notes]: getting-started/creating-new-notes.md "Creating New Notes"
|
||||
[write-notes-in-foam]: getting-started/write-notes-in-foam.md "Writing Notes"
|
||||
[sync-notes-with-source-control]: getting-started/sync-notes-with-source-control.md "Sync notes with source control"
|
||||
[first-workspace]: getting-started/first-workspace.md "Creating Your First Workspace"
|
||||
[note-taking-in-foam]: getting-started/note-taking-in-foam.md "Note-Taking in Foam"
|
||||
[sync-notes]: getting-started/sync-notes.md "Sync notes with source control"
|
||||
[keyboard-shortcuts]: getting-started/keyboard-shortcuts.md "Keyboard Shortcuts"
|
||||
[wikilinks]: features/wikilinks.md "Wikilinks"
|
||||
[embeds]: features/embeds.md "Note Embeds"
|
||||
[tags]: features/tags.md "Tags"
|
||||
[backlinking]: features/backlinking.md "Backlinking"
|
||||
[backlinking]: features/backlinking.md "Backlinks"
|
||||
[daily-notes]: features/daily-notes.md "Daily Notes"
|
||||
[including-notes]: features/including-notes.md "Including notes in a note"
|
||||
[spell-checking]: features/spell-checking.md "Spell Checking"
|
||||
[graph-visualization]: features/graph-visualization.md "Graph Visualization"
|
||||
[graph-view]: features/graph-view.md "Graph Visualization"
|
||||
[note-properties]: features/note-properties.md "Note Properties"
|
||||
[note-templates]: features/note-templates.md "Note Templates"
|
||||
[templates]: features/templates.md "Note Templates"
|
||||
[paste-images-from-clipboard]: features/paste-images-from-clipboard.md "Paste Images from Clipboard"
|
||||
[custom-markdown-preview-styles]: features/custom-markdown-preview-styles.md "Custom Markdown Preview Styles"
|
||||
[link-reference-definitions]: features/link-reference-definitions.md "Link Reference Definitions"
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# Make Backlinks More Prominent
|
||||
|
||||
One of the most most common early feature requests in Foam is to make the Markdown Notes Backlinks Explorer more prominent.
|
||||
|
||||
This #recipe shows you how to do that.
|
||||
|
||||
At the moment, you can drag the explorer pane to your bottom pane, and either show it side by side with another pane, or have take the full width of the editor:
|
||||
|
||||

|
||||
|
||||
In the future we'll want to improve this feature by
|
||||
|
||||
- [[materialized-backlinks]]
|
||||
- Providing more context around back link reference
|
||||
- Could be done by tweaking Markdown Notes slightly. Maybe a user setting?
|
||||
- Make back links editable using [VS Code Search Editors](https://code.visualstudio.com/updates/v1_43#_search-editors)
|
||||
- [Suggested by @Jash on Discord](https://discordapp.com/channels/729975036148056075/729978910363746315/730999992419876956)
|
||||
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[materialized-backlinks]: ../../dev/proposals/materialized-backlinks.md "Materialized Backlinks (stub)"
|
||||
[//end]: # "Autogenerated link references"
|
||||
@@ -1,58 +0,0 @@
|
||||
# Custom Note Macros
|
||||
|
||||
This #recipe allows you to create custom note macros.
|
||||
|
||||
## Installation
|
||||
|
||||
**This extension is not included in the template**
|
||||
|
||||
To install search note-macros in vscode or head to [note-macros - Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=NeelyInnovations.note-macros)
|
||||
|
||||
## Instructions
|
||||
|
||||
### Run macro From command palette
|
||||
|
||||
Simply use `Ctrl+P` or `Alt+P` depend on your os, and type `Note Macros: Run A Macro` then chose the macro you want to execute.
|
||||
|
||||
### Create Custom Note Macros
|
||||
|
||||
Create your own custom macros by adding them to your `settings.json` (Code|File > Preferences > User Settings). A full example can be found at [settings.json](https://github.com/kneely/note-macros/blob/master/settings.json)
|
||||
|
||||
For example:
|
||||
|
||||
This macro creates a Weekly note in the Weekly note Directory.
|
||||
|
||||
```json
|
||||
{
|
||||
"note-macros": {
|
||||
"Weekly": [
|
||||
{
|
||||
"type": "note",
|
||||
"directory": "Weekly",
|
||||
"extension": ".md",
|
||||
"name": "weekly-note",
|
||||
"date": "yyyy-W"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For an explanation of the fields please go to [note-macros - Explanation of Fields](https://github.com/kneely/note-macros#explanation-of-fields)
|
||||
|
||||
### Add Keybindings to Run your Macros
|
||||
|
||||
in `keybindings.json` (Code|File > Preferences > Keyboard Shortcuts) add bindings to your macros:
|
||||
|
||||
```json
|
||||
{
|
||||
"key": "ctrl+cmd+/",
|
||||
"command": "note-macros.Weekly"
|
||||
}
|
||||
```
|
||||
|
||||
## Issues and Feedback
|
||||
|
||||
If you have any issues or questions please look at the [README.md](https://github.com/kneely/note-macros#note-macros) on the [note-macros](https://github.com/kneely/note-macros) GitHub.
|
||||
|
||||
If you run into any issues that are not fixed by referring to the README or feature requests please open an [issue](https://github.com/kneely/note-macros/issues).
|
||||
@@ -30,7 +30,7 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
|
||||
|
||||
## Discover
|
||||
|
||||
- Explore your notes using [[graph-visualization]]
|
||||
- Explore your notes using [[graph-view]]
|
||||
- Discover relationships with [[backlinking]]
|
||||
- Simulating [[unlinked-references]]
|
||||
|
||||
@@ -44,9 +44,8 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
|
||||
- Use shortcuts for [[creating-new-notes]]
|
||||
- Instantly create and access your [[daily-notes]]
|
||||
- Add and explore [[tags]]
|
||||
- Create [[note-templates]]
|
||||
- Create [[templates]]
|
||||
- Find [[orphans]]
|
||||
- Use custom [[note-macros]] to create weekly, monthly etc. notes
|
||||
- Draw [[diagrams-in-markdown]]
|
||||
- Prettify your links, [[automatically-expand-urls-to-well-titled-links]]
|
||||
- Style your environment with [[custom-markdown-preview-styles]]
|
||||
@@ -59,7 +58,7 @@ A #recipe is a guide, tip or strategy for getting the most out of your Foam work
|
||||
- _More..._
|
||||
- VS Code Advanced Features [[todo]] [[good-first-task]]
|
||||
- Focus with Zen Mode
|
||||
- Display content of other notes in the preview tab by [[including-notes]]
|
||||
- Display content of other notes in the preview tab by [[embeds]]
|
||||
|
||||
## Version control
|
||||
|
||||
@@ -124,7 +123,6 @@ _See [[contribution-guide]] and [[how-to-write-recipes]]._
|
||||
[tags]: ../features/tags.md "Tags"
|
||||
[note-templates]: ../features/note-templates.md "Note Templates"
|
||||
[orphans]: ../tools/orphans.md "Orphaned Notes"
|
||||
[note-macros]: note-macros.md "Custom Note Macros"
|
||||
[diagrams-in-markdown]: diagrams-in-markdown.md "Diagrams in Markdown"
|
||||
[automatically-expand-urls-to-well-titled-links]: automatically-expand-urls-to-well-titled-links.md "Automatically Expand URLs to Well-Titled Links"
|
||||
[custom-markdown-preview-styles]: ../features/custom-markdown-preview-styles.md "Custom Markdown Preview Styles"
|
||||
|
||||
@@ -4,5 +4,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "0.27.1"
|
||||
"version": "0.29.1"
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"clean": "lerna run clean",
|
||||
"build": "lerna run build",
|
||||
"test": "yarn workspace foam-vscode test",
|
||||
"test:unit": "yarn workspace foam-vscode test:unit",
|
||||
"lint": "lerna run lint",
|
||||
"watch": "lerna run watch --concurrency 20"
|
||||
},
|
||||
|
||||
@@ -4,6 +4,104 @@ All notable changes to the "foam-vscode" extension will be documented in this fi
|
||||
|
||||
Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how to structure this file.
|
||||
|
||||
## 0.29.1
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Load graph in code server (#1400)
|
||||
- Don't treat text in single brackets as links/placeholders if missing a ref (#1545, #1546)
|
||||
- Added option to show graph on startup (#1542)
|
||||
- Added include patterns for Foam notes (#1550, #1422)
|
||||
- Added support for emoji variants in tags (#1536, #1549)
|
||||
- Added support for wikilink with aliases within tables (#1544, #1552)
|
||||
|
||||
## 0.29.0
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Improved support for wikilink references (#1531, #1116, #1504)
|
||||
- Improved tag search to include YAML tags (#1530, #1516)
|
||||
- Improved template filepath sanitization (#1533)
|
||||
- Added FOAM_DATE_WEEK_YEAR (#1532 - thanks @ChThH)
|
||||
- Fixed graph panel moving when revealed - graph now stays in its current location (#1540)
|
||||
|
||||
## [0.28.3] - 2025-10-03
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Fixed sanitation of filepath for templates (#1529 #1526)
|
||||
|
||||
## [0.28.2] - 2025-10-01
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Fixed build for web extension (#1523)
|
||||
|
||||
## [0.28.1] - 2025-09-25
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Removed duplicate links in dataviz graph (#1511 - thanks @Tenormis)
|
||||
- Use letter case to further disambiguate note identifiers (#1519, #1303)
|
||||
- Sanitize `filepath` before creating note from template (#1520, #1216)
|
||||
|
||||
## [0.28.0] - 2025-09-24
|
||||
|
||||
Features:
|
||||
|
||||
- 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:
|
||||
|
||||
- Fixed URI handling across scheme/authority (fixes #1404)
|
||||
|
||||
## [0.27.5] - 2025-09-06
|
||||
|
||||
Features:
|
||||
|
||||
- Added `FOAM_CURRENT_DIR` template variable for explicit current directory context (#1507)
|
||||
|
||||
## [0.27.4] - 2025-09-05
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Fixed double template application when using absolute `filepath` properties (#1499)
|
||||
|
||||
## [0.27.3] - 2025-09-05
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Improved timezone handling for create-note when passing string date
|
||||
- Added debugging for daily note issue (#1505, #1502, #1494)
|
||||
- Deprecated daily note settings (use daily-note template instead)
|
||||
|
||||
## [0.27.2] - 2025-07-25
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
- Ensure absolute paths used in create-note command are relative to workspace
|
||||
- Improved Windows path handling in URIs
|
||||
|
||||
## [0.27.1] - 2025-07-24
|
||||
|
||||
Fixes and Improvements:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
# Foam for VSCode
|
||||
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
|
||||
[](https://foambubble.github.io/join-discord/g)
|
||||
|
||||
> You can join the Foam Community on the [Foam Discord](https://foambubble.github.io/join-discord/e)
|
||||
|
||||
|
||||
@@ -25,6 +25,9 @@ const config = {
|
||||
platform: 'browser',
|
||||
format: 'cjs',
|
||||
outfile: `out/bundles/extension-web.js`,
|
||||
define: {
|
||||
global: 'globalThis',
|
||||
},
|
||||
plugins: [
|
||||
polyfillPlugin.polyfillNode({
|
||||
// Options (optional)
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"type": "git"
|
||||
},
|
||||
"homepage": "https://github.com/foambubble/foam",
|
||||
"version": "0.27.1",
|
||||
"version": "0.29.1",
|
||||
"license": "MIT",
|
||||
"publisher": "foam",
|
||||
"engines": {
|
||||
@@ -82,6 +82,13 @@
|
||||
"name": "Placeholders",
|
||||
"icon": "$(debug-disconnect)",
|
||||
"contextualTitle": "Foam"
|
||||
},
|
||||
{
|
||||
"when": "config.foam.experimental.ai",
|
||||
"id": "foam-vscode.related-notes",
|
||||
"name": "Related Notes (AI)",
|
||||
"icon": "$(sparkle)",
|
||||
"contextualTitle": "Foam"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -101,9 +108,46 @@
|
||||
{
|
||||
"view": "foam-vscode.placeholders",
|
||||
"contents": "No placeholders found for selected resource or workspace."
|
||||
},
|
||||
{
|
||||
"view": "foam-vscode.related-notes",
|
||||
"contents": "Open a note to see related notes.",
|
||||
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'no-note'"
|
||||
},
|
||||
{
|
||||
"view": "foam-vscode.related-notes",
|
||||
"contents": "Notes haven't been analyzed yet.\n[Analyze Notes](command:foam-vscode.build-embeddings)\nAnalyze your notes to discover similar content.",
|
||||
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'no-embedding'"
|
||||
},
|
||||
{
|
||||
"view": "foam-vscode.related-notes",
|
||||
"contents": "No similar notes found for the current note.",
|
||||
"when": "config.foam.experimental.ai && foam.relatedNotes.state == 'ready'"
|
||||
}
|
||||
],
|
||||
"menus": {
|
||||
"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",
|
||||
@@ -345,12 +389,32 @@
|
||||
"title": "Foam: Open Resource"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.convert-link-style-inplace",
|
||||
"title": "Foam: Convert Link Style in Place"
|
||||
"command": "foam-vscode.convert-wikilink-to-mdlink",
|
||||
"title": "Foam: Convert Wikilink to Markdown Link"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.convert-link-style-incopy",
|
||||
"title": "Foam: Convert Link Format in Copy"
|
||||
"command": "foam-vscode.convert-mdlink-to-wikilink",
|
||||
"title": "Foam: Convert Markdown Link to Wikilink"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.search-tag",
|
||||
"title": "Foam: Search Tag",
|
||||
"icon": "$(search)"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.rename-tag",
|
||||
"title": "Foam: Rename Tag",
|
||||
"icon": "$(edit)"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.show-similar-notes",
|
||||
"title": "Foam: Show Similar Notes",
|
||||
"when": "config.foam.experimental.ai"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.build-embeddings",
|
||||
"title": "Foam: Build Embeddings Index",
|
||||
"when": "config.foam.experimental.ai"
|
||||
},
|
||||
{
|
||||
"command": "foam-vscode.views.orphans.group-by:folder",
|
||||
@@ -494,6 +558,19 @@
|
||||
"Use alias if resource path is different from title"
|
||||
]
|
||||
},
|
||||
"foam.completion.linkFormat": {
|
||||
"type": "string",
|
||||
"default": "wikilink",
|
||||
"description": "Controls the format of completed links",
|
||||
"enum": [
|
||||
"wikilink",
|
||||
"link"
|
||||
],
|
||||
"enumDescriptions": [
|
||||
"Complete as wikilinks (e.g., [[note-name]])",
|
||||
"Complete as markdown links (e.g., [Note Name](note-name.md))"
|
||||
]
|
||||
},
|
||||
"foam.files.ignore": {
|
||||
"type": [
|
||||
"array"
|
||||
@@ -504,7 +581,24 @@
|
||||
"**/_site/**/*",
|
||||
"**/node_modules/**/*"
|
||||
],
|
||||
"description": "Specifies the list of globs that will be ignored by Foam (e.g. they will not be considered when creating the graph). To ignore the all the content of a given folder, use `<folderName>/**/*`"
|
||||
"description": "Specifies the list of globs that will be ignored by Foam (e.g. they will not be considered when creating the graph). To ignore all the content of a given folder, use `<folderName>/**/*`",
|
||||
"deprecationMessage": "Use 'foam.files.exclude' instead. This setting will be removed in a future version."
|
||||
},
|
||||
"foam.files.exclude": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"default": [],
|
||||
"description": "Specifies the list of globs that will be excluded by Foam (e.g. they will not be considered when creating the graph). To exclude all the content of a given folder, use `<folderName>/**/*`. This setting is combined with 'foam.files.ignore' (deprecated) and 'files.exclude'."
|
||||
},
|
||||
"foam.files.include": {
|
||||
"type": [
|
||||
"array"
|
||||
],
|
||||
"default": [
|
||||
"**/*"
|
||||
],
|
||||
"description": "Specifies the list of glob patterns for files to include in Foam. Files must match at least one include pattern and not match any exclude patterns. Use this to limit Foam to specific directories (e.g., [\"notes/**\"]) or file types (e.g., [\"**/*.md\"]). Defaults to all files."
|
||||
},
|
||||
"foam.files.attachmentExtensions": {
|
||||
"type": "string",
|
||||
@@ -574,15 +668,18 @@
|
||||
"default": false
|
||||
},
|
||||
"foam.openDailyNote.fileExtension": {
|
||||
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
|
||||
"type": "string",
|
||||
"default": "md"
|
||||
},
|
||||
"foam.openDailyNote.filenameFormat": {
|
||||
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
|
||||
"type": "string",
|
||||
"default": "isoDate",
|
||||
"markdownDescription": "Specifies how the daily note filename is formatted. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
|
||||
},
|
||||
"foam.openDailyNote.titleFormat": {
|
||||
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -591,6 +688,7 @@
|
||||
"markdownDescription": "Specifies how the daily note title is formatted. Will default to the filename format if set to null. See the [dateformat docs](https://www.npmjs.com/package/dateformat) for valid formats"
|
||||
},
|
||||
"foam.openDailyNote.directory": {
|
||||
"deprecationMessage": "This setting is deprecated and will be removed in future versions. Please use the daily-note template: https://github.com/foambubble/foam/blob/fe0228bdcc647e85682670656b5f09203b1c2518/docs/user/features/daily-notes.md",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
@@ -628,6 +726,7 @@
|
||||
"description": "Whether or not to navigate to the target daily note when a daily note snippet is selected."
|
||||
},
|
||||
"foam.preview.embedNoteType": {
|
||||
"when": "config.foam.experimental.ai",
|
||||
"type": "string",
|
||||
"default": "full-card",
|
||||
"enum": [
|
||||
@@ -652,6 +751,11 @@
|
||||
"type": "object",
|
||||
"description": "Custom graph styling settings. An example is present in the documentation.",
|
||||
"default": {}
|
||||
},
|
||||
"foam.graph.onStartup": {
|
||||
"type": "boolean",
|
||||
"default": false,
|
||||
"description": "Whether to open the graph on startup."
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -675,14 +779,14 @@
|
||||
"test-reset-workspace": "rm -rf .test-workspace && mkdir .test-workspace && touch .test-workspace/.keep",
|
||||
"test-setup": "yarn compile && yarn build && yarn test-reset-workspace",
|
||||
"test": "yarn test-setup && node ./out/test/run-tests.js",
|
||||
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs",
|
||||
"test:unit-with-specs": "yarn test-setup && node ./out/test/run-tests.js --unit",
|
||||
"test:unit": "yarn test-setup && node ./out/test/run-tests.js --unit",
|
||||
"test:unit-without-specs": "yarn test-setup && node ./out/test/run-tests.js --unit --exclude-specs",
|
||||
"test:e2e": "yarn test-setup && node ./out/test/run-tests.js --e2e",
|
||||
"lint": "dts lint src",
|
||||
"clean": "rimraf out",
|
||||
"watch": "nodemon --watch 'src/**/*.ts' --exec 'yarn build' --ext ts",
|
||||
"vscode:start-debugging": "yarn clean && yarn watch",
|
||||
"package-extension": "npx vsce package --yarn",
|
||||
"package-extension": "npx @vscode/vsce@3.6.0 package --yarn",
|
||||
"install-extension": "code --install-extension ./foam-vscode-$npm_package_version.vsix",
|
||||
"open-in-browser": "vscode-test-web --quality=stable --browser=chromium --extensionDevelopmentPath=. ",
|
||||
"publish-extension-openvsx": "npx ovsx publish foam-vscode-$npm_package_version.vsix -p $OPENVSX_TOKEN",
|
||||
|
||||
17
packages/foam-vscode/src/ai/model/embedding-cache.ts
Normal file
17
packages/foam-vscode/src/ai/model/embedding-cache.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { ICache } from '../../core/utils/cache';
|
||||
|
||||
type Checksum = string;
|
||||
|
||||
/**
|
||||
* Cache entry for embeddings
|
||||
*/
|
||||
export interface EmbeddingCacheEntry {
|
||||
checksum: Checksum;
|
||||
embedding: number[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for embeddings, keyed by URI
|
||||
*/
|
||||
export type EmbeddingCache = ICache<URI, EmbeddingCacheEntry>;
|
||||
365
packages/foam-vscode/src/ai/model/embeddings.test.ts
Normal file
365
packages/foam-vscode/src/ai/model/embeddings.test.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
import { FoamEmbeddings } from './embeddings';
|
||||
import {
|
||||
EmbeddingProvider,
|
||||
EmbeddingProviderInfo,
|
||||
} from '../services/embedding-provider';
|
||||
import {
|
||||
createTestWorkspace,
|
||||
InMemoryDataStore,
|
||||
waitForExpect,
|
||||
} from '../../test/test-utils';
|
||||
import { URI } from '../../core/model/uri';
|
||||
|
||||
// Helper to create a simple mock provider
|
||||
class MockProvider implements EmbeddingProvider {
|
||||
async embed(text: string): Promise<number[]> {
|
||||
const vector = new Array(384).fill(0);
|
||||
vector[0] = text.length / 100; // Deterministic based on text length
|
||||
return vector;
|
||||
}
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return true;
|
||||
}
|
||||
getProviderInfo(): EmbeddingProviderInfo {
|
||||
return {
|
||||
name: 'Test Provider',
|
||||
type: 'local',
|
||||
model: { name: 'test-model', dimensions: 384 },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const ROOT = [URI.parse('/', 'file')];
|
||||
|
||||
describe('FoamEmbeddings', () => {
|
||||
describe('cosineSimilarity', () => {
|
||||
it('should return 1 for identical vectors', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const vector = [1, 2, 3, 4, 5];
|
||||
const similarity = embeddings.cosineSimilarity(vector, vector);
|
||||
expect(similarity).toBeCloseTo(1.0, 5);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should return 0 for orthogonal vectors', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const vec1 = [1, 0, 0];
|
||||
const vec2 = [0, 1, 0];
|
||||
const similarity = embeddings.cosineSimilarity(vec1, vec2);
|
||||
expect(similarity).toBeCloseTo(0.0, 5);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should return -1 for opposite vectors', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const vec1 = [1, 0, 0];
|
||||
const vec2 = [-1, 0, 0];
|
||||
const similarity = embeddings.cosineSimilarity(vec1, vec2);
|
||||
expect(similarity).toBeCloseTo(-1.0, 5);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should return 0 for zero vectors', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const vec1 = [0, 0, 0];
|
||||
const vec2 = [1, 2, 3];
|
||||
const similarity = embeddings.cosineSimilarity(vec1, vec2);
|
||||
expect(similarity).toBe(0);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should throw error for vectors of different lengths', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const vec1 = [1, 2, 3];
|
||||
const vec2 = [1, 2];
|
||||
expect(() => embeddings.cosineSimilarity(vec1, vec2)).toThrow();
|
||||
workspace.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateResource', () => {
|
||||
it('should create embedding for a resource', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
const noteUri = URI.parse('/path/to/note.md', 'file');
|
||||
datastore.set(noteUri, '# Test Note\n\nThis is test content');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
await embeddings.updateResource(noteUri);
|
||||
|
||||
const embedding = embeddings.getEmbedding(noteUri);
|
||||
expect(embedding).not.toBeNull();
|
||||
expect(embedding?.length).toBe(384);
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should remove embedding when resource is deleted', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
const noteUri = URI.parse('/path/to/note.md', 'file');
|
||||
datastore.set(noteUri, '# Test Note\n\nContent');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
await embeddings.updateResource(noteUri);
|
||||
expect(embeddings.getEmbedding(noteUri)).not.toBeNull();
|
||||
|
||||
workspace.delete(noteUri);
|
||||
await embeddings.updateResource(noteUri);
|
||||
|
||||
expect(embeddings.getEmbedding(noteUri)).toBeNull();
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should create different embeddings for different content', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
const note1Uri = URI.parse('/note1.md', 'file');
|
||||
const note2Uri = URI.parse('/note2.md', 'file');
|
||||
|
||||
// Same title, different content
|
||||
datastore.set(note1Uri, '# Same Title\n\nShort content');
|
||||
datastore.set(
|
||||
note2Uri,
|
||||
'# Same Title\n\nThis is much longer content that should produce a different embedding vector'
|
||||
);
|
||||
|
||||
await workspace.fetchAndSet(note1Uri);
|
||||
await workspace.fetchAndSet(note2Uri);
|
||||
|
||||
await embeddings.updateResource(note1Uri);
|
||||
await embeddings.updateResource(note2Uri);
|
||||
|
||||
const embedding1 = embeddings.getEmbedding(note1Uri);
|
||||
const embedding2 = embeddings.getEmbedding(note2Uri);
|
||||
|
||||
expect(embedding1).not.toBeNull();
|
||||
expect(embedding2).not.toBeNull();
|
||||
|
||||
// Embeddings should be different because content is different
|
||||
// Our mock provider uses text.length for the first vector component
|
||||
expect(embedding1![0]).not.toBe(embedding2![0]);
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasEmbeddings', () => {
|
||||
it('should return false when no embeddings exist', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
expect(embeddings.hasEmbeddings()).toBe(false);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should return true when embeddings exist', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
const noteUri = URI.parse('/path/to/note.md', 'file');
|
||||
datastore.set(noteUri, '# Note\n\nContent');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
await embeddings.updateResource(noteUri);
|
||||
|
||||
expect(embeddings.hasEmbeddings()).toBe(true);
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSimilar', () => {
|
||||
it('should return empty array when no embedding exists for target', () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
const uri = URI.parse('/path/to/note.md', 'file');
|
||||
|
||||
const similar = embeddings.getSimilar(uri, 5);
|
||||
|
||||
expect(similar).toEqual([]);
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should return similar notes sorted by similarity', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
// Create notes with different content lengths
|
||||
const note1Uri = URI.parse('/note1.md', 'file');
|
||||
const note2Uri = URI.parse('/note2.md', 'file');
|
||||
const note3Uri = URI.parse('/note3.md', 'file');
|
||||
|
||||
datastore.set(note1Uri, '# Note 1\n\nShort');
|
||||
datastore.set(note2Uri, '# Note 2\n\nMedium length text');
|
||||
datastore.set(note3Uri, '# Note 3\n\nVery long text content here');
|
||||
|
||||
await workspace.fetchAndSet(note1Uri);
|
||||
await workspace.fetchAndSet(note2Uri);
|
||||
await workspace.fetchAndSet(note3Uri);
|
||||
|
||||
await embeddings.updateResource(note1Uri);
|
||||
await embeddings.updateResource(note2Uri);
|
||||
await embeddings.updateResource(note3Uri);
|
||||
|
||||
// Get similar to note2
|
||||
const similar = embeddings.getSimilar(note2Uri, 10);
|
||||
|
||||
expect(similar.length).toBe(2); // Excludes self
|
||||
expect(similar[0].uri.path).toBeTruthy();
|
||||
expect(similar[0].similarity).toBeGreaterThanOrEqual(
|
||||
similar[1].similarity
|
||||
);
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should respect topK parameter', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
// Create multiple notes
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const noteUri = URI.parse(`/note${i}.md`, 'file');
|
||||
datastore.set(noteUri, `# Note ${i}\n\nContent ${i}`);
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
await embeddings.updateResource(noteUri);
|
||||
}
|
||||
|
||||
const target = URI.parse('/note0.md', 'file');
|
||||
const similar = embeddings.getSimilar(target, 3);
|
||||
|
||||
expect(similar.length).toBe(3);
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should not include self in similar results', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = new FoamEmbeddings(workspace, new MockProvider());
|
||||
|
||||
const noteUri = URI.parse('/note.md', 'file');
|
||||
datastore.set(noteUri, '# Note\n\nContent');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
await embeddings.updateResource(noteUri);
|
||||
|
||||
const similar = embeddings.getSimilar(noteUri, 10);
|
||||
|
||||
expect(similar.find(s => s.uri.path === noteUri.path)).toBeUndefined();
|
||||
|
||||
workspace.dispose();
|
||||
});
|
||||
});
|
||||
|
||||
describe('fromWorkspace with monitoring', () => {
|
||||
it('should automatically update when resource is added', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const embeddings = FoamEmbeddings.fromWorkspace(
|
||||
workspace,
|
||||
new MockProvider(),
|
||||
true
|
||||
);
|
||||
|
||||
const noteUri = URI.parse('/new-note.md', 'file');
|
||||
datastore.set(noteUri, '# New Note\n\nContent');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
// Give it a moment to process
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
const embedding = embeddings.getEmbedding(noteUri);
|
||||
expect(embedding).not.toBeNull();
|
||||
|
||||
embeddings.dispose();
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should automatically update when resource is modified', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const noteUri = URI.parse('/note.md', 'file');
|
||||
|
||||
datastore.set(noteUri, '# Note\n\nOriginal content');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
const embeddings = FoamEmbeddings.fromWorkspace(
|
||||
workspace,
|
||||
new MockProvider(),
|
||||
true
|
||||
);
|
||||
|
||||
await embeddings.updateResource(noteUri);
|
||||
const originalEmbedding = embeddings.getEmbedding(noteUri);
|
||||
|
||||
// Update the content of the note to simulate a change
|
||||
datastore.set(noteUri, '# Note\n\nDifferent content that is much longer');
|
||||
|
||||
// Trigger workspace update event
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
// Wait for automatic update
|
||||
await waitForExpect(
|
||||
() => {
|
||||
const newEmbedding = embeddings.getEmbedding(noteUri);
|
||||
expect(newEmbedding).not.toEqual(originalEmbedding);
|
||||
},
|
||||
1000,
|
||||
50
|
||||
);
|
||||
|
||||
embeddings.dispose();
|
||||
workspace.dispose();
|
||||
});
|
||||
|
||||
it('should automatically remove embedding when resource is deleted', async () => {
|
||||
const datastore = new InMemoryDataStore();
|
||||
const workspace = createTestWorkspace(ROOT, datastore);
|
||||
const noteUri = URI.parse('/note.md', 'file');
|
||||
|
||||
datastore.set(noteUri, '# Note\n\nContent');
|
||||
await workspace.fetchAndSet(noteUri);
|
||||
|
||||
const embeddings = FoamEmbeddings.fromWorkspace(
|
||||
workspace,
|
||||
new MockProvider(),
|
||||
true
|
||||
);
|
||||
|
||||
await embeddings.updateResource(noteUri);
|
||||
expect(embeddings.getEmbedding(noteUri)).not.toBeNull();
|
||||
|
||||
workspace.delete(noteUri);
|
||||
|
||||
// Give it a moment to process
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
expect(embeddings.getEmbedding(noteUri)).toBeNull();
|
||||
|
||||
embeddings.dispose();
|
||||
workspace.dispose();
|
||||
});
|
||||
});
|
||||
});
|
||||
382
packages/foam-vscode/src/ai/model/embeddings.ts
Normal file
382
packages/foam-vscode/src/ai/model/embeddings.ts
Normal file
@@ -0,0 +1,382 @@
|
||||
import { Emitter } from '../../core/common/event';
|
||||
import { IDisposable } from '../../core/common/lifecycle';
|
||||
import { Logger } from '../../core/utils/log';
|
||||
import { hash } from '../../core/utils';
|
||||
import { EmbeddingProvider, Embedding } from '../services/embedding-provider';
|
||||
import { EmbeddingCache } from './embedding-cache';
|
||||
import {
|
||||
ProgressCallback,
|
||||
CancellationToken,
|
||||
CancellationError,
|
||||
} from '../../core/services/progress';
|
||||
import { FoamWorkspace } from '../../core/model/workspace';
|
||||
import { URI } from '../../core/model/uri';
|
||||
|
||||
/**
|
||||
* Represents a similar resource with its similarity score
|
||||
*/
|
||||
export interface SimilarResource {
|
||||
uri: URI;
|
||||
similarity: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Context information for embedding progress
|
||||
*/
|
||||
export interface EmbeddingProgressContext {
|
||||
/** URI of the current resource */
|
||||
uri: URI;
|
||||
/** Title of the current resource */
|
||||
title: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages embeddings for all resources in the workspace
|
||||
*/
|
||||
export class FoamEmbeddings implements IDisposable {
|
||||
/**
|
||||
* Maps resource URIs to their embeddings
|
||||
*/
|
||||
private embeddings: Map<string, Embedding> = new Map();
|
||||
|
||||
private onDidUpdateEmitter = new Emitter<void>();
|
||||
onDidUpdate = this.onDidUpdateEmitter.event;
|
||||
|
||||
/**
|
||||
* List of disposables to destroy with the embeddings
|
||||
*/
|
||||
private disposables: IDisposable[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly workspace: FoamWorkspace,
|
||||
private readonly provider: EmbeddingProvider,
|
||||
private readonly cache?: EmbeddingCache
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the embedding for a resource
|
||||
* @param uri The URI of the resource
|
||||
* @returns The embedding vector, or null if not found
|
||||
*/
|
||||
public getEmbedding(uri: URI): number[] | null {
|
||||
const embedding = this.embeddings.get(uri.path);
|
||||
return embedding ? embedding.vector : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if embeddings are available
|
||||
* @returns true if at least one embedding exists
|
||||
*/
|
||||
public hasEmbeddings(): boolean {
|
||||
return this.embeddings.size > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of embeddings
|
||||
* @returns The count of embeddings
|
||||
*/
|
||||
public size(): number {
|
||||
return this.embeddings.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find similar resources to a given resource
|
||||
* @param uri The URI of the target resource
|
||||
* @param topK The number of similar resources to return
|
||||
* @returns Array of similar resources sorted by similarity (highest first)
|
||||
*/
|
||||
public getSimilar(uri: URI, topK: number = 10): SimilarResource[] {
|
||||
const targetEmbedding = this.getEmbedding(uri);
|
||||
if (!targetEmbedding) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const similarities: SimilarResource[] = [];
|
||||
|
||||
for (const [path, embedding] of this.embeddings.entries()) {
|
||||
// Skip self
|
||||
if (path === uri.path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const similarity = this.cosineSimilarity(
|
||||
targetEmbedding,
|
||||
embedding.vector
|
||||
);
|
||||
similarities.push({
|
||||
uri: URI.file(path),
|
||||
similarity,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by similarity (highest first) and take top K
|
||||
similarities.sort((a, b) => b.similarity - a.similarity);
|
||||
return similarities.slice(0, topK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cosine similarity between two vectors
|
||||
* @param a First vector
|
||||
* @param b Second vector
|
||||
* @returns Similarity score between -1 and 1 (higher is more similar)
|
||||
*/
|
||||
public cosineSimilarity(a: number[], b: number[]): number {
|
||||
if (a.length !== b.length) {
|
||||
throw new Error('Vectors must have the same length');
|
||||
}
|
||||
|
||||
let dotProduct = 0;
|
||||
let normA = 0;
|
||||
let normB = 0;
|
||||
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
dotProduct += a[i] * b[i];
|
||||
normA += a[i] * a[i];
|
||||
normB += b[i] * b[i];
|
||||
}
|
||||
|
||||
const denominator = Math.sqrt(normA) * Math.sqrt(normB);
|
||||
if (denominator === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return dotProduct / denominator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update embeddings for a single resource
|
||||
* @param uri The URI of the resource to update
|
||||
* @returns The embedding vector, or null if not found/not processed
|
||||
*/
|
||||
public async updateResource(uri: URI): Promise<Embedding | null> {
|
||||
const resource = this.workspace.find(uri);
|
||||
if (!resource) {
|
||||
// Resource deleted, remove embedding
|
||||
this.embeddings.delete(uri.path);
|
||||
if (this.cache) {
|
||||
this.cache.del(uri);
|
||||
}
|
||||
this.onDidUpdateEmitter.fire();
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip non-note resources (attachments)
|
||||
if (resource.type !== 'note') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = await this.workspace.readAsMarkdown(resource.uri);
|
||||
const text = this.prepareTextForEmbedding(resource.title, content);
|
||||
const textChecksum = hash(text);
|
||||
|
||||
// Check cache if available
|
||||
if (this.cache && this.cache.has(uri)) {
|
||||
const cached = this.cache.get(uri);
|
||||
if (cached.checksum === textChecksum) {
|
||||
Logger.debug(
|
||||
`Skipping embedding for ${uri.toFsPath()} - content unchanged`
|
||||
);
|
||||
// Use cached embedding
|
||||
const embedding: Embedding = {
|
||||
vector: cached.embedding,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.embeddings.set(uri.path, embedding);
|
||||
return embedding;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new embedding
|
||||
const vector = await this.provider.embed(text);
|
||||
|
||||
const embedding: Embedding = {
|
||||
vector,
|
||||
createdAt: Date.now(),
|
||||
};
|
||||
this.embeddings.set(uri.path, embedding);
|
||||
|
||||
// Update cache
|
||||
if (this.cache) {
|
||||
this.cache.set(uri, {
|
||||
checksum: textChecksum,
|
||||
embedding: vector,
|
||||
});
|
||||
}
|
||||
|
||||
this.onDidUpdateEmitter.fire();
|
||||
return embedding;
|
||||
} catch (error) {
|
||||
Logger.error(`Failed to update embedding for ${uri.toFsPath()}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update embeddings for all notes, processing only missing or stale ones
|
||||
* @param onProgress Optional callback to report progress
|
||||
* @param cancellationToken Optional token to cancel the operation
|
||||
* @returns Promise that resolves when all embeddings are updated
|
||||
* @throws CancellationError if the operation is cancelled
|
||||
*/
|
||||
public async update(
|
||||
onProgress?: ProgressCallback<EmbeddingProgressContext>,
|
||||
cancellationToken?: CancellationToken
|
||||
): Promise<void> {
|
||||
const start = Date.now();
|
||||
|
||||
// Filter to only process notes (not attachments)
|
||||
const allResources = Array.from(this.workspace.resources());
|
||||
const resources = allResources.filter(r => r.type === 'note');
|
||||
|
||||
Logger.info(
|
||||
`Building embeddings for ${resources.length} notes (${allResources.length} total resources)...`
|
||||
);
|
||||
|
||||
let skipped = 0;
|
||||
let generated = 0;
|
||||
let reused = 0;
|
||||
|
||||
// Process embeddings sequentially to avoid overwhelming the service
|
||||
for (let i = 0; i < resources.length; i++) {
|
||||
// Check for cancellation
|
||||
if (cancellationToken?.isCancellationRequested) {
|
||||
Logger.info(
|
||||
`Embedding build cancelled. Processed ${i}/${resources.length} notes.`
|
||||
);
|
||||
throw new CancellationError('Embedding build cancelled');
|
||||
}
|
||||
|
||||
const resource = resources[i];
|
||||
|
||||
onProgress?.({
|
||||
current: i + 1,
|
||||
total: resources.length,
|
||||
context: {
|
||||
uri: resource.uri,
|
||||
title: resource.title,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const content = await this.workspace.readAsMarkdown(resource.uri);
|
||||
const text = this.prepareTextForEmbedding(resource.title, content);
|
||||
const textChecksum = hash(text);
|
||||
|
||||
// Check cache if available
|
||||
if (this.cache && this.cache.has(resource.uri)) {
|
||||
const cached = this.cache.get(resource.uri);
|
||||
if (cached.checksum === textChecksum) {
|
||||
// Check if we already have this embedding in memory
|
||||
const existing = this.embeddings.get(resource.uri.path);
|
||||
if (existing) {
|
||||
// Already have current embedding, skip
|
||||
reused++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Restore from cache
|
||||
this.embeddings.set(resource.uri.path, {
|
||||
vector: cached.embedding,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate new embedding
|
||||
const vector = await this.provider.embed(text);
|
||||
this.embeddings.set(resource.uri.path, {
|
||||
vector,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
// Update cache
|
||||
if (this.cache) {
|
||||
this.cache.set(resource.uri, {
|
||||
checksum: textChecksum,
|
||||
embedding: vector,
|
||||
});
|
||||
}
|
||||
|
||||
generated++;
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
`Failed to generate embedding for ${resource.uri.toFsPath()}`,
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const end = Date.now();
|
||||
Logger.info(
|
||||
`Embeddings update complete: ${generated} generated, ${skipped} from cache, ${reused} already current (${
|
||||
this.embeddings.size
|
||||
}/${resources.length} total) in ${end - start}ms`
|
||||
);
|
||||
this.onDidUpdateEmitter.fire();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare text for embedding by combining title and content
|
||||
* @param title The title of the note
|
||||
* @param content The markdown content of the note
|
||||
* @returns The combined text to embed
|
||||
*/
|
||||
private prepareTextForEmbedding(title: string, content: string): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (title) {
|
||||
parts.push(title);
|
||||
}
|
||||
|
||||
if (content) {
|
||||
parts.push(content);
|
||||
}
|
||||
|
||||
return parts.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create FoamEmbeddings from a workspace
|
||||
* @param workspace The workspace to generate embeddings for
|
||||
* @param provider The embedding provider to use
|
||||
* @param keepMonitoring Whether to automatically update embeddings when workspace changes
|
||||
* @param cache Optional cache for storing embeddings
|
||||
* @returns The FoamEmbeddings instance
|
||||
*/
|
||||
public static fromWorkspace(
|
||||
workspace: FoamWorkspace,
|
||||
provider: EmbeddingProvider,
|
||||
keepMonitoring: boolean = false,
|
||||
cache?: EmbeddingCache
|
||||
): FoamEmbeddings {
|
||||
const embeddings = new FoamEmbeddings(workspace, provider, cache);
|
||||
|
||||
if (keepMonitoring) {
|
||||
// Update embeddings when resources change
|
||||
embeddings.disposables.push(
|
||||
workspace.onDidAdd(resource => {
|
||||
embeddings.updateResource(resource.uri);
|
||||
}),
|
||||
workspace.onDidUpdate(({ new: resource }) => {
|
||||
embeddings.updateResource(resource.uri);
|
||||
}),
|
||||
workspace.onDidDelete(resource => {
|
||||
embeddings.embeddings.delete(resource.uri.path);
|
||||
embeddings.onDidUpdateEmitter.fire();
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return embeddings;
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.onDidUpdateEmitter.dispose();
|
||||
this.disposables.forEach(d => d.dispose());
|
||||
this.disposables = [];
|
||||
this.embeddings.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { EmbeddingCache, EmbeddingCacheEntry } from './embedding-cache';
|
||||
|
||||
/**
|
||||
* Simple in-memory implementation of embedding cache
|
||||
*/
|
||||
export class InMemoryEmbeddingCache implements EmbeddingCache {
|
||||
private cache: Map<string, EmbeddingCacheEntry> = new Map();
|
||||
|
||||
get(uri: URI): EmbeddingCacheEntry {
|
||||
return this.cache.get(uri.toString());
|
||||
}
|
||||
|
||||
has(uri: URI): boolean {
|
||||
return this.cache.has(uri.toString());
|
||||
}
|
||||
|
||||
set(uri: URI, entry: EmbeddingCacheEntry): void {
|
||||
this.cache.set(uri.toString(), entry);
|
||||
}
|
||||
|
||||
del(uri: URI): void {
|
||||
this.cache.delete(uri.toString());
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.cache.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import { Logger } from '../../../core/utils/log';
|
||||
import {
|
||||
OllamaEmbeddingProvider,
|
||||
DEFAULT_OLLAMA_CONFIG,
|
||||
} from './ollama-provider';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('OllamaEmbeddingProvider', () => {
|
||||
const originalFetch = global.fetch;
|
||||
beforeEach(() => {
|
||||
global.fetch = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
global.fetch = originalFetch;
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('should use default config when no config provided', () => {
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const config = provider.getConfig();
|
||||
|
||||
expect(config.url).toBe(DEFAULT_OLLAMA_CONFIG.url);
|
||||
expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);
|
||||
expect(config.timeout).toBe(DEFAULT_OLLAMA_CONFIG.timeout);
|
||||
});
|
||||
|
||||
it('should merge custom config with defaults', () => {
|
||||
const provider = new OllamaEmbeddingProvider({
|
||||
url: 'http://custom:11434',
|
||||
});
|
||||
const config = provider.getConfig();
|
||||
|
||||
expect(config.url).toBe('http://custom:11434');
|
||||
expect(config.model).toBe(DEFAULT_OLLAMA_CONFIG.model);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getProviderInfo', () => {
|
||||
it('should return provider information', () => {
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const info = provider.getProviderInfo();
|
||||
|
||||
expect(info.name).toBe('Ollama');
|
||||
expect(info.type).toBe('local');
|
||||
expect(info.model.name).toBe('nomic-embed-text');
|
||||
expect(info.model.dimensions).toBe(768);
|
||||
expect(info.endpoint).toBe('http://localhost:11434');
|
||||
expect(info.description).toBe('Local embedding provider using Ollama');
|
||||
expect(info.metadata).toEqual({ timeout: 30000 });
|
||||
});
|
||||
|
||||
it('should return custom model name when configured', () => {
|
||||
const provider = new OllamaEmbeddingProvider({
|
||||
model: 'custom-model',
|
||||
});
|
||||
const info = provider.getProviderInfo();
|
||||
|
||||
expect(info.model.name).toBe('custom-model');
|
||||
});
|
||||
|
||||
it('should return custom endpoint when configured', () => {
|
||||
const provider = new OllamaEmbeddingProvider({
|
||||
url: 'http://custom:8080',
|
||||
});
|
||||
const info = provider.getProviderInfo();
|
||||
|
||||
expect(info.endpoint).toBe('http://custom:8080');
|
||||
});
|
||||
});
|
||||
|
||||
describe('embed', () => {
|
||||
it('should successfully generate embeddings', async () => {
|
||||
const mockEmbedding = new Array(768).fill(0.1);
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ embeddings: [mockEmbedding] }),
|
||||
});
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const result = await provider.embed('test text');
|
||||
|
||||
expect(result).toEqual(mockEmbedding);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/embed',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'nomic-embed-text',
|
||||
input: ['test text'],
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on non-ok response', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
text: async () => 'Internal server error',
|
||||
});
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
|
||||
await expect(provider.embed('test')).rejects.toThrow(
|
||||
'AI service error (500)'
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error on connection refused', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('fetch failed')
|
||||
);
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
|
||||
await expect(provider.embed('test')).rejects.toThrow(
|
||||
'Cannot connect to Ollama'
|
||||
);
|
||||
});
|
||||
|
||||
it('should timeout after configured duration', async () => {
|
||||
(global.fetch as jest.Mock).mockImplementationOnce(
|
||||
(_url, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
// Simulate abort signal being triggered
|
||||
options.signal.addEventListener('abort', () => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const provider = new OllamaEmbeddingProvider({ timeout: 1000 });
|
||||
const embedPromise = provider.embed('test');
|
||||
|
||||
// Fast-forward time to trigger timeout
|
||||
jest.advanceTimersByTime(1001);
|
||||
|
||||
await expect(embedPromise).rejects.toThrow('AI service took too long');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAvailable', () => {
|
||||
it('should return true when Ollama is available', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
});
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const result = await provider.isAvailable();
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'http://localhost:11434/api/tags',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should return false when Ollama is not available', async () => {
|
||||
(global.fetch as jest.Mock).mockRejectedValueOnce(
|
||||
new Error('Connection refused')
|
||||
);
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const result = await provider.isAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when Ollama returns non-ok status', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
});
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const result = await provider.isAvailable();
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should timeout quickly (5s) when checking availability', async () => {
|
||||
(global.fetch as jest.Mock).mockImplementationOnce(
|
||||
(_url, options) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
// Simulate abort signal being triggered
|
||||
options.signal.addEventListener('abort', () => {
|
||||
const error = new Error('The operation was aborted');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
const availabilityPromise = provider.isAvailable();
|
||||
|
||||
// Fast-forward time to trigger timeout (5s for availability check)
|
||||
jest.advanceTimersByTime(5001);
|
||||
|
||||
const result = await availabilityPromise;
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('OllamaEmbeddingProvider - Integration', () => {
|
||||
const provider = new OllamaEmbeddingProvider();
|
||||
|
||||
it('should handle text with unicode characters and emojis', async () => {
|
||||
if (!(await provider.isAvailable())) {
|
||||
console.warn('Ollama is not available, skipping test');
|
||||
return;
|
||||
}
|
||||
const text = 'Task completed ✔ 🚀: All systems go! 🌟';
|
||||
const embedding = await provider.embed(text);
|
||||
|
||||
expect(embedding).toBeDefined();
|
||||
expect(Array.isArray(embedding)).toBe(true);
|
||||
expect(embedding.length).toBe(768); // nomic-embed-text dimension
|
||||
expect(embedding.every(n => typeof n === 'number')).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle text with various unicode characters', async () => {
|
||||
if (!(await provider.isAvailable())) {
|
||||
console.warn('Ollama is not available, skipping test');
|
||||
return;
|
||||
}
|
||||
const text = 'Hello 🌍 with émojis and spëcial çharacters • bullet ✓ check';
|
||||
const embedding = await provider.embed(text);
|
||||
|
||||
expect(embedding).toBeDefined();
|
||||
expect(Array.isArray(embedding)).toBe(true);
|
||||
expect(embedding.length).toBe(768);
|
||||
});
|
||||
|
||||
it('should handle text with combining unicode characters', async () => {
|
||||
if (!(await provider.isAvailable())) {
|
||||
console.warn('Ollama is not available, skipping test');
|
||||
return;
|
||||
}
|
||||
// Test with combining diacriticals that could be represented differently
|
||||
const text = 'café vs cafe\u0301'; // Two ways to represent é
|
||||
const embedding = await provider.embed(text);
|
||||
|
||||
expect(embedding).toBeDefined();
|
||||
expect(Array.isArray(embedding)).toBe(true);
|
||||
expect(embedding.length).toBe(768);
|
||||
});
|
||||
|
||||
it('should handle empty text', async () => {
|
||||
if (!(await provider.isAvailable())) {
|
||||
console.warn('Ollama is not available, skipping test');
|
||||
return;
|
||||
}
|
||||
const text = '';
|
||||
const embedding = await provider.embed(text);
|
||||
|
||||
expect(embedding).toBeDefined();
|
||||
expect(Array.isArray(embedding)).toBe(true);
|
||||
// Note: Ollama returns empty array for empty text
|
||||
expect(embedding.length).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it.each([10, 50, 60, 100, 300])(
|
||||
'should handle text of various lengths',
|
||||
async length => {
|
||||
if (!(await provider.isAvailable())) {
|
||||
console.warn('Ollama is not available, skipping test');
|
||||
return;
|
||||
}
|
||||
const text = 'Lorem ipsum dolor sit amet. '.repeat(length);
|
||||
try {
|
||||
const embedding = await provider.embed(text);
|
||||
expect(embedding).toBeDefined();
|
||||
expect(Array.isArray(embedding)).toBe(true);
|
||||
expect(embedding.length).toBe(768);
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Embedding failed for text of length ${text.length}: ${error}`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
166
packages/foam-vscode/src/ai/providers/ollama/ollama-provider.ts
Normal file
166
packages/foam-vscode/src/ai/providers/ollama/ollama-provider.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import {
|
||||
EmbeddingProvider,
|
||||
EmbeddingProviderInfo,
|
||||
} from '../../services/embedding-provider';
|
||||
import { Logger } from '../../../core/utils/log';
|
||||
|
||||
/**
|
||||
* Configuration for Ollama embedding provider
|
||||
*/
|
||||
export interface OllamaConfig {
|
||||
/** Base URL for Ollama API (default: http://localhost:11434) */
|
||||
url: string;
|
||||
/** Model name to use for embeddings (default: nomic-embed-text) */
|
||||
model: string;
|
||||
/** Request timeout in milliseconds (default: 30000) */
|
||||
timeout: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Default configuration for Ollama
|
||||
*/
|
||||
export const DEFAULT_OLLAMA_CONFIG: OllamaConfig = {
|
||||
url: 'http://localhost:11434',
|
||||
model: 'nomic-embed-text',
|
||||
timeout: 30000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Ollama API response for embeddings
|
||||
*/
|
||||
interface OllamaEmbeddingResponse {
|
||||
embeddings: number[][];
|
||||
}
|
||||
|
||||
/**
|
||||
* Embedding provider that uses Ollama for generating embeddings
|
||||
*/
|
||||
export class OllamaEmbeddingProvider implements EmbeddingProvider {
|
||||
private config: OllamaConfig;
|
||||
|
||||
constructor(config: Partial<OllamaConfig> = {}) {
|
||||
this.config = { ...DEFAULT_OLLAMA_CONFIG, ...config };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an embedding for the given text
|
||||
*/
|
||||
async embed(text: string): Promise<number[]> {
|
||||
// normalize text to suitable input (format and size)
|
||||
// TODO we should better handle long texts by chunking them and averaging embeddings
|
||||
const input = text.substring(0, 6000).normalize();
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(
|
||||
() => controller.abort(),
|
||||
this.config.timeout
|
||||
);
|
||||
|
||||
const response = await fetch(`${this.config.url}/api/embed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: this.config.model,
|
||||
input: [input],
|
||||
}),
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(`AI service error (${response.status}): ${errorText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data.embeddings == null) {
|
||||
throw new Error(
|
||||
`Invalid response from AI service: ${JSON.stringify(data)}`
|
||||
);
|
||||
}
|
||||
return data.embeddings[0];
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'AbortError') {
|
||||
throw new Error(
|
||||
'AI service took too long to respond. It may be busy processing another request.'
|
||||
);
|
||||
}
|
||||
if (
|
||||
error.message.includes('fetch') ||
|
||||
error.message.includes('ECONNREFUSED')
|
||||
) {
|
||||
throw new Error(
|
||||
`Cannot connect to Ollama at ${this.config.url}. Make sure Ollama is installed and running.`
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Ollama is available and the model is accessible
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
// Try to reach the Ollama API
|
||||
const response = await fetch(`${this.config.url}/api/tags`, {
|
||||
method: 'GET',
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
Logger.warn(
|
||||
`Ollama API returned status ${response.status} when checking availability`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
Logger.debug(
|
||||
`Ollama not available at ${this.config.url}: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get provider information including model details
|
||||
*/
|
||||
getProviderInfo(): EmbeddingProviderInfo {
|
||||
return {
|
||||
name: 'Ollama',
|
||||
type: 'local',
|
||||
model: {
|
||||
name: this.config.model,
|
||||
// nomic-embed-text produces 768-dimensional embeddings
|
||||
dimensions: 768,
|
||||
},
|
||||
description: 'Local embedding provider using Ollama',
|
||||
endpoint: this.config.url,
|
||||
metadata: {
|
||||
timeout: this.config.timeout,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current configuration
|
||||
*/
|
||||
getConfig(): OllamaConfig {
|
||||
return { ...this.config };
|
||||
}
|
||||
}
|
||||
61
packages/foam-vscode/src/ai/services/embedding-provider.ts
Normal file
61
packages/foam-vscode/src/ai/services/embedding-provider.ts
Normal file
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Information about an embedding provider and its model
|
||||
*/
|
||||
export interface EmbeddingProviderInfo {
|
||||
/** Human-readable name of the provider (e.g., "Ollama", "OpenAI") */
|
||||
name: string;
|
||||
|
||||
/** Type of provider */
|
||||
type: 'local' | 'remote';
|
||||
|
||||
/** Model information */
|
||||
model: {
|
||||
/** Model name (e.g., "nomic-embed-text", "text-embedding-3-small") */
|
||||
name: string;
|
||||
/** Vector dimensions */
|
||||
dimensions: number;
|
||||
};
|
||||
|
||||
/** Optional description of the provider */
|
||||
description?: string;
|
||||
|
||||
/** Backend endpoint/URL if applicable */
|
||||
endpoint?: string;
|
||||
|
||||
/** Additional provider-specific metadata */
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider interface for generating text embeddings
|
||||
*/
|
||||
export interface EmbeddingProvider {
|
||||
/**
|
||||
* Generate an embedding vector for the given text
|
||||
* @param text The text to embed
|
||||
* @returns A promise that resolves to the embedding vector
|
||||
*/
|
||||
embed(text: string): Promise<number[]>;
|
||||
|
||||
/**
|
||||
* Check if the embedding service is available and ready to use
|
||||
* @returns A promise that resolves to true if available, false otherwise
|
||||
*/
|
||||
isAvailable(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Get information about the provider and its model
|
||||
* @returns Provider metadata including name, type, model info, and configuration
|
||||
*/
|
||||
getProviderInfo(): EmbeddingProviderInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a text embedding with metadata
|
||||
*/
|
||||
export interface Embedding {
|
||||
/** The embedding vector */
|
||||
vector: number[];
|
||||
/** Timestamp when the embedding was created */
|
||||
createdAt: number;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EmbeddingProvider, EmbeddingProviderInfo } from './embedding-provider';
|
||||
|
||||
/**
|
||||
* A no-op embedding provider that does nothing.
|
||||
* Used when no real embedding provider is available.
|
||||
*/
|
||||
export class NoOpEmbeddingProvider implements EmbeddingProvider {
|
||||
async embed(_text: string): Promise<number[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return false;
|
||||
}
|
||||
|
||||
getProviderInfo(): EmbeddingProviderInfo {
|
||||
return {
|
||||
name: 'None',
|
||||
type: 'local',
|
||||
model: {
|
||||
name: 'none',
|
||||
dimensions: 0,
|
||||
},
|
||||
description: 'No embedding provider configured',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
/* @unit-ready */
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
createFile,
|
||||
deleteFile,
|
||||
waitForNoteInFoamWorkspace,
|
||||
} from '../../../test/test-utils-vscode';
|
||||
import { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';
|
||||
|
||||
describe('build-embeddings command', () => {
|
||||
it('should complete successfully with no notes to analyze', async () => {
|
||||
await cleanWorkspace();
|
||||
|
||||
const showInfoSpy = jest
|
||||
.spyOn(vscode.window, 'showInformationMessage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await vscode.commands.executeCommand<
|
||||
'complete' | 'cancelled' | 'error'
|
||||
>(BUILD_EMBEDDINGS_COMMAND.command);
|
||||
|
||||
expect(result).toBe('complete');
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No notes found')
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('should analyze notes and report completion', async () => {
|
||||
const note1 = await createFile('# Note 1\nContent here', ['note1.md']);
|
||||
const note2 = await createFile('# Note 2\nMore content', ['note2.md']);
|
||||
|
||||
await waitForNoteInFoamWorkspace(note1.uri);
|
||||
await waitForNoteInFoamWorkspace(note2.uri);
|
||||
|
||||
const showInfoSpy = jest
|
||||
.spyOn(vscode.window, 'showInformationMessage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await vscode.commands.executeCommand<
|
||||
'complete' | 'cancelled' | 'error'
|
||||
>(BUILD_EMBEDDINGS_COMMAND.command);
|
||||
|
||||
expect(result).toBe('complete');
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/Analyzed.*2/)
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(note1.uri);
|
||||
await deleteFile(note2.uri);
|
||||
});
|
||||
|
||||
it('should return cancelled status when operation is cancelled', async () => {
|
||||
const note1 = await createFile('# Note 1\nContent', ['note1.md']);
|
||||
await waitForNoteInFoamWorkspace(note1.uri);
|
||||
|
||||
const tokenSource = new vscode.CancellationTokenSource();
|
||||
|
||||
const withProgressSpy = jest
|
||||
.spyOn(vscode.window, 'withProgress')
|
||||
.mockImplementation(async (options, task) => {
|
||||
const progress = { report: () => {} };
|
||||
// Cancel immediately
|
||||
tokenSource.cancel();
|
||||
return await task(progress, tokenSource.token);
|
||||
});
|
||||
|
||||
const showInfoSpy = jest
|
||||
.spyOn(vscode.window, 'showInformationMessage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
const result = await vscode.commands.executeCommand<
|
||||
'complete' | 'cancelled' | 'error'
|
||||
>(BUILD_EMBEDDINGS_COMMAND.command);
|
||||
|
||||
expect(result).toBe('cancelled');
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('cancelled')
|
||||
);
|
||||
|
||||
withProgressSpy.mockRestore();
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(note1.uri);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../../../core/model/foam';
|
||||
import { CancellationError } from '../../../core/services/progress';
|
||||
import { TaskDeduplicator } from '../../../core/utils/task-deduplicator';
|
||||
import { FoamWorkspace } from '../../../core/model/workspace';
|
||||
import { FoamEmbeddings } from '../../../ai/model/embeddings';
|
||||
|
||||
export const BUILD_EMBEDDINGS_COMMAND = {
|
||||
command: 'foam-vscode.build-embeddings',
|
||||
title: 'Foam: Analyze Notes with AI',
|
||||
};
|
||||
|
||||
export default async function activate(
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
// Deduplicate concurrent executions
|
||||
const deduplicator = new TaskDeduplicator<
|
||||
'complete' | 'cancelled' | 'error'
|
||||
>();
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
BUILD_EMBEDDINGS_COMMAND.command,
|
||||
async () => {
|
||||
return await deduplicator.run(
|
||||
() => buildEmbeddings(foam.workspace, foam.embeddings),
|
||||
() => {
|
||||
vscode.window.showInformationMessage(
|
||||
'Note analysis is already in progress - waiting for it to complete'
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function buildEmbeddings(
|
||||
workspace: FoamWorkspace,
|
||||
embeddings: FoamEmbeddings
|
||||
): Promise<'complete' | 'cancelled' | 'error'> {
|
||||
const notesCount = workspace.list().filter(r => r.type === 'note').length;
|
||||
|
||||
if (notesCount === 0) {
|
||||
vscode.window.showInformationMessage('No notes found in workspace');
|
||||
return 'complete';
|
||||
}
|
||||
|
||||
// Show progress notification
|
||||
return await vscode.window.withProgress(
|
||||
{
|
||||
location: vscode.ProgressLocation.Window,
|
||||
title: 'Analyzing notes',
|
||||
cancellable: true,
|
||||
},
|
||||
async (progress, token) => {
|
||||
try {
|
||||
await embeddings.update(progressInfo => {
|
||||
const title = progressInfo.context?.title || 'Processing...';
|
||||
const increment = (1 / progressInfo.total) * 100;
|
||||
progress.report({
|
||||
message: `${progressInfo.current}/${progressInfo.total} - ${title}`,
|
||||
increment: increment,
|
||||
});
|
||||
}, token);
|
||||
|
||||
vscode.window.showInformationMessage(
|
||||
`✓ Analyzed ${embeddings.size()} of ${notesCount} notes`
|
||||
);
|
||||
return 'complete';
|
||||
} catch (error) {
|
||||
if (error instanceof CancellationError) {
|
||||
vscode.window.showInformationMessage(
|
||||
'Analysis cancelled. Run the command again to continue where you left off.'
|
||||
);
|
||||
return 'cancelled';
|
||||
}
|
||||
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to analyze notes: ${errorMessage}`
|
||||
);
|
||||
return 'error';
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../../../core/model/foam';
|
||||
import { fromVsCodeUri, toVsCodeUri } from '../../../utils/vsc-utils';
|
||||
import { URI } from '../../../core/model/uri';
|
||||
import { BUILD_EMBEDDINGS_COMMAND } from './build-embeddings';
|
||||
|
||||
export const SHOW_SIMILAR_NOTES_COMMAND = {
|
||||
command: 'foam-vscode.show-similar-notes',
|
||||
title: 'Foam: Show Similar Notes',
|
||||
};
|
||||
|
||||
export default async function activate(
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(
|
||||
SHOW_SIMILAR_NOTES_COMMAND.command,
|
||||
async () => {
|
||||
await showSimilarNotes(foam);
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function showSimilarNotes(foam: Foam): Promise<void> {
|
||||
// Get the active editor
|
||||
const editor = vscode.window.activeTextEditor;
|
||||
if (!editor) {
|
||||
vscode.window.showInformationMessage('Please open a note first');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the URI of the active document
|
||||
const uri = fromVsCodeUri(editor.document.uri);
|
||||
|
||||
// Check if the resource exists in workspace
|
||||
const resource = foam.workspace.find(uri);
|
||||
if (!resource) {
|
||||
vscode.window.showInformationMessage('This file is not a note');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure embeddings are up-to-date (incremental update)
|
||||
const status: 'complete' | 'error' | 'cancelled' =
|
||||
await vscode.commands.executeCommand(BUILD_EMBEDDINGS_COMMAND.command);
|
||||
|
||||
if (status !== 'complete') {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if embedding exists for this resource
|
||||
const embedding = foam.embeddings.getEmbedding(uri);
|
||||
if (!embedding) {
|
||||
vscode.window.showInformationMessage(
|
||||
'This note hasn\'t been analyzed yet. Make sure the AI service is running and try the "Analyze Notes with AI" command.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Get similar notes
|
||||
const similar = foam.embeddings.getSimilar(uri, 10);
|
||||
|
||||
if (similar.length === 0) {
|
||||
vscode.window.showInformationMessage('No similar notes found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create quick pick items
|
||||
const items: vscode.QuickPickItem[] = similar.map(item => {
|
||||
const resource = foam.workspace.find(item.uri);
|
||||
const title = resource?.title || item.uri.getBasename();
|
||||
const similarityPercent = (item.similarity * 100).toFixed(1);
|
||||
|
||||
return {
|
||||
label: `$(file) ${title}`,
|
||||
description: `${similarityPercent}% similar`,
|
||||
detail: item.uri.toFsPath(),
|
||||
uri: item.uri,
|
||||
} as vscode.QuickPickItem & { uri: URI };
|
||||
});
|
||||
|
||||
// Show quick pick
|
||||
const selected = await vscode.window.showQuickPick(items, {
|
||||
placeHolder: 'Select a similar note to open',
|
||||
matchOnDescription: true,
|
||||
matchOnDetail: true,
|
||||
});
|
||||
|
||||
if (selected) {
|
||||
const selectedUri = (selected as any).uri as URI;
|
||||
const doc = await vscode.workspace.openTextDocument(
|
||||
toVsCodeUri(selectedUri)
|
||||
);
|
||||
await vscode.window.showTextDocument(doc);
|
||||
}
|
||||
}
|
||||
138
packages/foam-vscode/src/ai/vscode/panels/related-notes.ts
Normal file
138
packages/foam-vscode/src/ai/vscode/panels/related-notes.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../../../core/model/foam';
|
||||
import { FoamWorkspace } from '../../../core/model/workspace';
|
||||
import { URI } from '../../../core/model/uri';
|
||||
import { fromVsCodeUri } from '../../../utils/vsc-utils';
|
||||
import { BaseTreeProvider } from '../../../features/panels/utils/base-tree-provider';
|
||||
import { ResourceTreeItem } from '../../../features/panels/utils/tree-view-utils';
|
||||
import { FoamEmbeddings } from '../../../ai/model/embeddings';
|
||||
|
||||
export default async function activate(
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
const provider = new RelatedNotesTreeDataProvider(
|
||||
foam.workspace,
|
||||
foam.embeddings,
|
||||
context.globalState
|
||||
);
|
||||
|
||||
const treeView = vscode.window.createTreeView('foam-vscode.related-notes', {
|
||||
treeDataProvider: provider,
|
||||
showCollapseAll: false,
|
||||
});
|
||||
|
||||
const updateTreeView = async () => {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
provider.target = activeEditor
|
||||
? fromVsCodeUri(activeEditor.document.uri)
|
||||
: undefined;
|
||||
await provider.refresh();
|
||||
|
||||
// Update context for conditional viewsWelcome messages
|
||||
vscode.commands.executeCommand(
|
||||
'setContext',
|
||||
'foam.relatedNotes.state',
|
||||
provider.getState()
|
||||
);
|
||||
};
|
||||
|
||||
updateTreeView();
|
||||
|
||||
context.subscriptions.push(
|
||||
provider,
|
||||
treeView,
|
||||
foam.embeddings.onDidUpdate(() => updateTreeView()),
|
||||
vscode.window.onDidChangeActiveTextEditor(() => updateTreeView()),
|
||||
provider.onDidChangeTreeData(() => {
|
||||
treeView.title = `Related Notes (${provider.nValues})`;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export class RelatedNotesTreeDataProvider extends BaseTreeProvider<vscode.TreeItem> {
|
||||
public target?: URI = undefined;
|
||||
public nValues = 0;
|
||||
private relatedNotes: Array<{ uri: URI; similarity: number }> = [];
|
||||
private currentNoteHasEmbedding = false;
|
||||
|
||||
constructor(
|
||||
private workspace: FoamWorkspace,
|
||||
private embeddings: FoamEmbeddings,
|
||||
public state: vscode.Memento
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
const uri = this.target;
|
||||
|
||||
// Clear if no target or target is not a note
|
||||
if (!uri) {
|
||||
this.relatedNotes = [];
|
||||
this.nValues = 0;
|
||||
this.currentNoteHasEmbedding = false;
|
||||
super.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
const resource = this.workspace.find(uri);
|
||||
if (!resource || resource.type !== 'note') {
|
||||
this.relatedNotes = [];
|
||||
this.nValues = 0;
|
||||
this.currentNoteHasEmbedding = false;
|
||||
super.refresh();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if current note has an embedding
|
||||
this.currentNoteHasEmbedding = this.embeddings.getEmbedding(uri) !== null;
|
||||
|
||||
// Get similar notes (user can click "Build Embeddings" button if needed)
|
||||
const similar = this.embeddings.getSimilar(uri, 10);
|
||||
this.relatedNotes = similar.filter(n => n.similarity > 0.6);
|
||||
this.nValues = this.relatedNotes.length;
|
||||
super.refresh();
|
||||
}
|
||||
|
||||
async getChildren(item?: vscode.TreeItem): Promise<vscode.TreeItem[]> {
|
||||
if (item) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If no related notes found, show appropriate message in viewsWelcome
|
||||
// The empty array will trigger the viewsWelcome content
|
||||
if (this.relatedNotes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return this.relatedNotes
|
||||
.map(({ uri, similarity }) => {
|
||||
const resource = this.workspace.find(uri);
|
||||
if (!resource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const item = new ResourceTreeItem(resource, this.workspace);
|
||||
// Show similarity score as percentage in description
|
||||
item.description = `${Math.round(similarity * 100)}%`;
|
||||
return item;
|
||||
})
|
||||
.filter(item => item !== null) as ResourceTreeItem[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current state of the related notes panel
|
||||
*/
|
||||
public getState(): 'no-note' | 'no-embedding' | 'ready' {
|
||||
if (!this.target) {
|
||||
return 'no-note';
|
||||
}
|
||||
if (!this.currentNoteHasEmbedding) {
|
||||
return 'no-embedding';
|
||||
}
|
||||
return 'ready';
|
||||
}
|
||||
}
|
||||
@@ -1,14 +1,9 @@
|
||||
import { Resource, ResourceLink } from '../model/note';
|
||||
import { URI } from '../model/uri';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { isNone } from '../utils';
|
||||
import { MarkdownLink } from '../services/markdown-link';
|
||||
|
||||
export interface LinkReplace {
|
||||
newText: string;
|
||||
range: Range /* old range */;
|
||||
}
|
||||
import { TextEdit } from '../services/text-edit';
|
||||
|
||||
/**
|
||||
* convert a link based on its workspace and the note containing it.
|
||||
@@ -27,7 +22,7 @@ export function convertLinkFormat(
|
||||
targetFormat: 'wikilink' | 'link',
|
||||
workspace: FoamWorkspace,
|
||||
note: Resource | URI
|
||||
): LinkReplace {
|
||||
): TextEdit {
|
||||
const resource = note instanceof URI ? workspace.find(note) : note;
|
||||
const targetUri = workspace.resolveLink(resource, link);
|
||||
/* If it's already the target format or a placeholder, no transformation happens */
|
||||
|
||||
@@ -1,209 +1,13 @@
|
||||
import { generateLinkReferences } from '.';
|
||||
import { TEST_DATA_DIR } from '../../test/test-utils';
|
||||
import { MarkdownResourceProvider } from '../services/markdown-provider';
|
||||
import { Resource } from '../model/note';
|
||||
import { Range } from '../model/range';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { Logger } from '../utils/log';
|
||||
import fs from 'fs';
|
||||
import { URI } from '../model/uri';
|
||||
import { EOL } from 'os';
|
||||
import { createMarkdownParser } from '../services/markdown-parser';
|
||||
import { FileDataStore } from '../../test/test-datastore';
|
||||
import { TextEdit } from '../services/text-edit';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('generateLinkReferences', () => {
|
||||
let _workspace: FoamWorkspace;
|
||||
// TODO slug must be reserved for actual slugs, not file names
|
||||
const findBySlug = (slug: string): Resource => {
|
||||
return _workspace
|
||||
.list()
|
||||
.find(res => res.uri.getName() === slug) as Resource;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
/** Use fs for reading files in units where vscode.workspace is unavailable */
|
||||
const readFile = async (uri: URI) =>
|
||||
(await fs.promises.readFile(uri.toFsPath())).toString();
|
||||
const dataStore = new FileDataStore(
|
||||
readFile,
|
||||
TEST_DATA_DIR.joinPath('__scaffold__').toFsPath()
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
|
||||
_workspace = await FoamWorkspace.fromProviders([mdProvider], dataStore);
|
||||
});
|
||||
|
||||
it('initialised test graph correctly', () => {
|
||||
expect(_workspace.list().length).toEqual(11);
|
||||
});
|
||||
|
||||
it('should add link references to a file that does not have them', async () => {
|
||||
const note = findBySlug('index');
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[second-document]: second-document "Second Document"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(9, 0, 9, 0),
|
||||
};
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should remove link definitions from a file that has them, if no links are present', async () => {
|
||||
const note = findBySlug('second-document');
|
||||
|
||||
const expected = {
|
||||
newText: '',
|
||||
range: Range.create(6, 0, 8, 42),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should update link definitions if they are present but changed', async () => {
|
||||
const note = findBySlug('first-document');
|
||||
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(8, 0, 10, 42),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should not cause any changes if link reference definitions were up to date', async () => {
|
||||
const note = findBySlug('third-document');
|
||||
|
||||
const expected = null;
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should put links with spaces in angel brackets', async () => {
|
||||
const note = findBySlug('angel-reference');
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`
|
||||
[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[Note being referred as angel]: <Note being referred as angel> "Note being referred as angel"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(3, 0, 3, 0),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual!.range.start).toEqual(expected.range.start);
|
||||
expect(actual!.range.end).toEqual(expected.range.end);
|
||||
expect(actual!.newText).toEqual(expected.newText);
|
||||
});
|
||||
|
||||
it('should not remove explicitly entered link references', async () => {
|
||||
const note = findBySlug('file-with-explicit-link-references');
|
||||
const expected = null;
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
|
||||
it('should not remove explicitly entered link references and have an implicit link', async () => {
|
||||
const note = findBySlug('file-with-explicit-and-implicit-link-references');
|
||||
const expected = {
|
||||
newText: textForNote(
|
||||
`[//begin]: # "Autogenerated link references for markdown compatibility"
|
||||
[first-document]: first-document "First Document"
|
||||
[//end]: # "Autogenerated link references"`
|
||||
),
|
||||
range: Range.create(8, 0, 10, 42),
|
||||
};
|
||||
|
||||
const noteText = await _workspace.readAsMarkdown(note.uri);
|
||||
const noteEol = EOL;
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
noteEol,
|
||||
_workspace,
|
||||
false
|
||||
);
|
||||
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Will adjust a text line separator to match
|
||||
* what is used by the note
|
||||
@@ -216,3 +20,347 @@ function textForNote(text: string): string {
|
||||
const eol = EOL;
|
||||
return text.split('\n').join(eol);
|
||||
}
|
||||
|
||||
describe('generateLinkReferences', () => {
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
interface TestCase {
|
||||
case: string;
|
||||
input: string;
|
||||
expected: string;
|
||||
}
|
||||
|
||||
const testCases: TestCase[] = [
|
||||
{
|
||||
case: 'should add link references for wikilinks present in note',
|
||||
input: `
|
||||
# Index
|
||||
[[doc1]] [[doc2]] [[file-without-title]]
|
||||
`,
|
||||
expected: `
|
||||
# Index
|
||||
[[doc1]] [[doc2]] [[file-without-title]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
[doc2]: doc2 "Second"
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: '#1558 - should keep a blank line before link references',
|
||||
input: `
|
||||
# Test
|
||||
|
||||
[[doc1]]
|
||||
|
||||
[[doc2]]
|
||||
|
||||
|
||||
|
||||
`,
|
||||
expected: `
|
||||
# Test
|
||||
|
||||
[[doc1]]
|
||||
|
||||
[[doc2]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should remove obsolete link definitions',
|
||||
input: `
|
||||
# Document
|
||||
Some content here.
|
||||
[doc1]: doc1 "First"
|
||||
`,
|
||||
expected: `
|
||||
# Document
|
||||
Some content here.
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should add and remove link definitions as needed',
|
||||
input: `
|
||||
# First Document
|
||||
|
||||
Here's some [unrelated] content.
|
||||
|
||||
[unrelated]: http://unrelated.com 'This link should not be changed'
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
[doc2]: doc2 'Second Document'
|
||||
`,
|
||||
expected: `
|
||||
# First Document
|
||||
|
||||
Here's some [unrelated] content.
|
||||
|
||||
[unrelated]: http://unrelated.com 'This link should not be changed'
|
||||
|
||||
[[file-without-title]]
|
||||
|
||||
|
||||
|
||||
[file-without-title]: file-without-title "file-without-title"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should not change correct link references',
|
||||
input: `
|
||||
# Third Document
|
||||
All the link references are correct in this file.
|
||||
|
||||
[[doc1]]
|
||||
[[doc2]]
|
||||
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
expected: `
|
||||
# Third Document
|
||||
All the link references are correct in this file.
|
||||
|
||||
[[doc1]]
|
||||
[[doc2]]
|
||||
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should put links with spaces in angel brackets',
|
||||
input: `
|
||||
# Angel reference
|
||||
|
||||
[[Angel note]]
|
||||
`,
|
||||
expected: `
|
||||
# Angel reference
|
||||
|
||||
[[Angel note]]
|
||||
|
||||
[Angel note]: <Angel note> "Angel note"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should not remove explicitly entered link references',
|
||||
input: `
|
||||
# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference]
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
`,
|
||||
expected: `
|
||||
# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference]
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should not change explicitly entered link references',
|
||||
input: `
|
||||
# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference].
|
||||
I also want a [[doc1]].
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
`,
|
||||
expected: `
|
||||
# File with explicit link references
|
||||
|
||||
A Bug [^footerlink]. Here is [Another link][linkreference].
|
||||
I also want a [[doc1]].
|
||||
|
||||
[^footerlink]: https://foambubble.github.io/
|
||||
|
||||
[linkreference]: https://foambubble.github.io/
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle empty file with no wikilinks and no definitions',
|
||||
input: `
|
||||
# Empty Document
|
||||
|
||||
Just some text without any links.
|
||||
`,
|
||||
expected: `
|
||||
# Empty Document
|
||||
|
||||
Just some text without any links.
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle wikilinks with aliases',
|
||||
input: `
|
||||
# Document with aliases
|
||||
|
||||
[[doc1|Custom Alias]] and [[doc2|Another Alias]]
|
||||
`,
|
||||
expected: `
|
||||
# Document with aliases
|
||||
|
||||
[[doc1|Custom Alias]] and [[doc2|Another Alias]]
|
||||
|
||||
[doc1|Custom Alias]: doc1 "First"
|
||||
[doc2|Another Alias]: doc2 "Second"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should generate only one definition for multiple references to the same link',
|
||||
input: `
|
||||
# Multiple references
|
||||
|
||||
First mention: [[doc1]]
|
||||
Second mention: [[doc1]]
|
||||
Third mention: [[doc1]]
|
||||
`,
|
||||
expected: `
|
||||
# Multiple references
|
||||
|
||||
First mention: [[doc1]]
|
||||
Second mention: [[doc1]]
|
||||
Third mention: [[doc1]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle link definitions in the middle of content',
|
||||
input: `
|
||||
# Document
|
||||
|
||||
[[doc1]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
|
||||
Some more content here.
|
||||
|
||||
[[doc2]]
|
||||
`,
|
||||
expected: `
|
||||
# Document
|
||||
|
||||
[[doc1]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
|
||||
Some more content here.
|
||||
|
||||
[[doc2]]
|
||||
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle orphaned wikilinks without corresponding notes',
|
||||
input: `
|
||||
# Document with broken links
|
||||
|
||||
[[doc1]] [[nonexistent]] [[another-missing]]
|
||||
`,
|
||||
expected: `
|
||||
# Document with broken links
|
||||
|
||||
[[doc1]] [[nonexistent]] [[another-missing]]
|
||||
|
||||
[doc1]: doc1 "First"
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle file with only blank lines at end',
|
||||
input: `
|
||||
|
||||
`,
|
||||
expected: `
|
||||
|
||||
`,
|
||||
},
|
||||
{
|
||||
case: 'should handle empty files',
|
||||
input: '',
|
||||
expected: '',
|
||||
},
|
||||
{
|
||||
case: 'should handle link definitions with different quote styles',
|
||||
input: `
|
||||
# Mixed quotes
|
||||
|
||||
[[doc1]] [[doc2]]
|
||||
|
||||
[doc1]: doc1 'First'
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
expected: `
|
||||
# Mixed quotes
|
||||
|
||||
[[doc1]] [[doc2]]
|
||||
|
||||
[doc1]: doc1 'First'
|
||||
[doc2]: doc2 "Second"
|
||||
`,
|
||||
},
|
||||
// TODO
|
||||
// {
|
||||
// case: 'should append new link references to existing ones without blank lines',
|
||||
// input: `
|
||||
// [[doc1]] [[doc2]]
|
||||
|
||||
// [doc1]: doc1 "First"
|
||||
// `,
|
||||
// expected: `
|
||||
// [[doc1]] [[doc2]]
|
||||
|
||||
// [doc1]: doc1 "First"
|
||||
// [doc2]: doc2 "Second"
|
||||
// `,
|
||||
// },
|
||||
];
|
||||
|
||||
testCases.forEach(testCase => {
|
||||
// eslint-disable-next-line jest/valid-title
|
||||
it(testCase.case, async () => {
|
||||
const workspace = createTestWorkspace([URI.file('/')]);
|
||||
const workspaceNotes = [
|
||||
{ uri: '/doc1.md', title: 'First' },
|
||||
{ uri: '/doc2.md', title: 'Second' },
|
||||
{ uri: '/file-without-title.md', title: 'file-without-title' },
|
||||
{ uri: '/Angel note.md', title: 'Angel note' },
|
||||
];
|
||||
workspaceNotes.forEach(note => {
|
||||
workspace.set(createTestNote({ uri: note.uri, title: note.title }));
|
||||
});
|
||||
|
||||
const noteText = testCase.input;
|
||||
const note = parser.parse(URI.file('/note.md'), textForNote(noteText));
|
||||
const actual = await generateLinkReferences(
|
||||
note,
|
||||
noteText,
|
||||
EOL,
|
||||
workspace,
|
||||
false
|
||||
);
|
||||
const updated = TextEdit.apply(noteText, actual);
|
||||
|
||||
expect(updated).toBe(textForNote(testCase.expected));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,78 +3,77 @@ import { Range } from '../model/range';
|
||||
import { createMarkdownReferences } from '../services/markdown-provider';
|
||||
import { FoamWorkspace } from '../model/workspace';
|
||||
import { TextEdit } from '../services/text-edit';
|
||||
import { Position } from '../model/position';
|
||||
import { getLinkDefinitions } from '../services/markdown-parser';
|
||||
|
||||
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
|
||||
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;
|
||||
|
||||
export const generateLinkReferences = async (
|
||||
note: Resource,
|
||||
text: string,
|
||||
currentNoteText: string,
|
||||
eol: string,
|
||||
workspace: FoamWorkspace,
|
||||
includeExtensions: boolean
|
||||
): Promise<TextEdit | null> => {
|
||||
): Promise<TextEdit[]> => {
|
||||
if (!note) {
|
||||
return null;
|
||||
return [];
|
||||
}
|
||||
|
||||
const newWikilinkDefinitions = createMarkdownReferences(
|
||||
const lines = currentNoteText.split(eol);
|
||||
const nLines = lines.length;
|
||||
|
||||
const updatedWikilinkDefinitions = createMarkdownReferences(
|
||||
workspace,
|
||||
note,
|
||||
includeExtensions
|
||||
);
|
||||
|
||||
const beginDelimiterDef = note.definitions.find(
|
||||
({ label }) => label === '//begin'
|
||||
const existingWikilinkDefinitions = getLinkDefinitions(currentNoteText);
|
||||
|
||||
const toAddWikilinkDefinitions = updatedWikilinkDefinitions.filter(
|
||||
newDef =>
|
||||
!existingWikilinkDefinitions.some(
|
||||
existingDef => existingDef.label === newDef.label
|
||||
)
|
||||
);
|
||||
const endDelimiterDef = note.definitions.find(
|
||||
({ label }) => label === '//end'
|
||||
const toRemovedWikilinkDefinitions = existingWikilinkDefinitions.filter(
|
||||
existingDef =>
|
||||
!updatedWikilinkDefinitions.some(
|
||||
newDef => newDef.label === existingDef.label
|
||||
)
|
||||
);
|
||||
|
||||
const lines = text.split(eol);
|
||||
const edits: TextEdit[] = [];
|
||||
|
||||
const targetRange =
|
||||
beginDelimiterDef && endDelimiterDef
|
||||
? Range.createFromPosition(
|
||||
beginDelimiterDef.range.start,
|
||||
endDelimiterDef.range.end
|
||||
)
|
||||
: Range.create(
|
||||
lines.length - 1,
|
||||
lines[lines.length - 1].length,
|
||||
lines.length - 1,
|
||||
lines[lines.length - 1].length
|
||||
);
|
||||
// Remove old definitions
|
||||
for (const def of toRemovedWikilinkDefinitions) {
|
||||
edits.push({ range: def.range, newText: '' });
|
||||
}
|
||||
|
||||
const newReferences =
|
||||
newWikilinkDefinitions.length === 0
|
||||
? ''
|
||||
: [
|
||||
LINK_REFERENCE_DEFINITION_HEADER,
|
||||
...newWikilinkDefinitions.map(NoteLinkDefinition.format),
|
||||
LINK_REFERENCE_DEFINITION_FOOTER,
|
||||
].join(eol);
|
||||
// Add new definitions
|
||||
if (toAddWikilinkDefinitions.length > 0) {
|
||||
// find the last non-empty line to append the definitions after it
|
||||
const lastLineIndex = nLines - 1;
|
||||
let insertLineIndex = lastLineIndex;
|
||||
while (insertLineIndex > 0 && lines[insertLineIndex].trim() === '') {
|
||||
insertLineIndex--;
|
||||
}
|
||||
|
||||
// check if the new references match the existing references
|
||||
const existingReferences = lines
|
||||
.slice(targetRange.start.line, targetRange.end.line + 1)
|
||||
.join(eol);
|
||||
const definitions = toAddWikilinkDefinitions.map(def =>
|
||||
NoteLinkDefinition.format(def)
|
||||
);
|
||||
const text = eol + eol + definitions.join(eol) + eol;
|
||||
|
||||
// adjust padding based on whether there are existing definitions
|
||||
// and, if not, whether we are on an empty line at the end of the file
|
||||
const padding =
|
||||
newWikilinkDefinitions.length === 0 || // no definitions
|
||||
!Position.isEqual(targetRange.start, targetRange.end) // replace existing definitions
|
||||
? ''
|
||||
: targetRange.start.character > 0 // not an empty line
|
||||
? `${eol}${eol}`
|
||||
: eol;
|
||||
edits.push({
|
||||
range: Range.create(
|
||||
insertLineIndex,
|
||||
lines[insertLineIndex].length,
|
||||
lastLineIndex,
|
||||
lines[lastLineIndex].length
|
||||
),
|
||||
newText: text,
|
||||
});
|
||||
}
|
||||
|
||||
return existingReferences === newReferences
|
||||
? null
|
||||
: {
|
||||
newText: `${padding}${newReferences}`,
|
||||
range: targetRange,
|
||||
};
|
||||
return edits;
|
||||
};
|
||||
|
||||
@@ -5,6 +5,10 @@ import { FoamGraph } from './graph';
|
||||
import { ResourceParser } from './note';
|
||||
import { ResourceProvider } from './provider';
|
||||
import { FoamTags } from './tags';
|
||||
import { FoamEmbeddings } from '../../ai/model/embeddings';
|
||||
import { InMemoryEmbeddingCache } from '../../ai/model/in-memory-embedding-cache';
|
||||
import { EmbeddingProvider } from '../../ai/services/embedding-provider';
|
||||
import { NoOpEmbeddingProvider } from '../../ai/services/noop-embedding-provider';
|
||||
import { Logger, withTiming, withTimingAsync } from '../utils/log';
|
||||
|
||||
export interface Services {
|
||||
@@ -18,6 +22,7 @@ export interface Foam extends IDisposable {
|
||||
workspace: FoamWorkspace;
|
||||
graph: FoamGraph;
|
||||
tags: FoamTags;
|
||||
embeddings: FoamEmbeddings;
|
||||
}
|
||||
|
||||
export const bootstrap = async (
|
||||
@@ -26,7 +31,8 @@ export const bootstrap = async (
|
||||
dataStore: IDataStore,
|
||||
parser: ResourceParser,
|
||||
initialProviders: ResourceProvider[],
|
||||
defaultExtension: string = '.md'
|
||||
defaultExtension: string = '.md',
|
||||
embeddingProvider?: EmbeddingProvider
|
||||
) => {
|
||||
const workspace = await withTimingAsync(
|
||||
() =>
|
||||
@@ -48,6 +54,22 @@ export const bootstrap = async (
|
||||
ms => Logger.info(`Tags loaded in ${ms}ms`)
|
||||
);
|
||||
|
||||
embeddingProvider = embeddingProvider ?? new NoOpEmbeddingProvider();
|
||||
const embeddings = FoamEmbeddings.fromWorkspace(
|
||||
workspace,
|
||||
embeddingProvider,
|
||||
true,
|
||||
new InMemoryEmbeddingCache()
|
||||
);
|
||||
|
||||
if (await embeddingProvider.isAvailable()) {
|
||||
Logger.info('Embeddings service initialized');
|
||||
} else {
|
||||
Logger.warn(
|
||||
'Embedding provider not available. Semantic features will be disabled.'
|
||||
);
|
||||
}
|
||||
|
||||
watcher?.onDidChange(async uri => {
|
||||
if (matcher.isMatch(uri)) {
|
||||
await workspace.fetchAndSet(uri);
|
||||
@@ -67,6 +89,7 @@ export const bootstrap = async (
|
||||
workspace,
|
||||
graph,
|
||||
tags,
|
||||
embeddings,
|
||||
services: {
|
||||
parser,
|
||||
dataStore,
|
||||
@@ -75,6 +98,7 @@ export const bootstrap = async (
|
||||
dispose: () => {
|
||||
workspace.dispose();
|
||||
graph.dispose();
|
||||
embeddings.dispose();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -146,7 +146,6 @@ describe('Graph', () => {
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/somewhere/page-b.md',
|
||||
text: '## Section 1\n\n## Section 2',
|
||||
});
|
||||
const ws = createTestWorkspace().set(noteA).set(noteB);
|
||||
const graph = FoamGraph.fromWorkspace(ws);
|
||||
@@ -264,15 +263,10 @@ describe('Placeholders', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/page-a.md',
|
||||
links: [{ slug: 'page-b' }, { slug: 'page-c' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: './page-b.md',
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-c',
|
||||
url: '/path/to/page-c.md',
|
||||
links: [
|
||||
{ slug: 'page-b', definitionUrl: './page-b.md' },
|
||||
{ slug: 'page-c', definitionUrl: '/path/to/page-c.md' },
|
||||
],
|
||||
});
|
||||
ws.set(noteA).set(
|
||||
createTestNote({ uri: '/different/location/for/note-b.md' })
|
||||
|
||||
@@ -6,6 +6,41 @@ export interface ResourceLink {
|
||||
rawText: string;
|
||||
range: Range;
|
||||
isEmbed: boolean;
|
||||
definition?: string | NoteLinkDefinition;
|
||||
}
|
||||
|
||||
export abstract class ResourceLink {
|
||||
/**
|
||||
* Check if this is any kind of reference-style link (resolved or unresolved)
|
||||
*/
|
||||
static isReferenceStyleLink(link: ResourceLink): boolean {
|
||||
return link.definition !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a reference-style link with unresolved definition
|
||||
*/
|
||||
static isUnresolvedReference(
|
||||
link: ResourceLink
|
||||
): link is ResourceLink & { definition: string } {
|
||||
return typeof link.definition === 'string';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a reference-style link with resolved definition
|
||||
*/
|
||||
static isResolvedReference(
|
||||
link: ResourceLink
|
||||
): link is ResourceLink & { definition: NoteLinkDefinition } {
|
||||
return typeof link.definition === 'object' && link.definition !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if this is a regular inline link (not reference-style)
|
||||
*/
|
||||
static isRegularLink(link: ResourceLink): boolean {
|
||||
return link.definition === undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoteLinkDefinition {
|
||||
@@ -26,6 +61,14 @@ export abstract class NoteLinkDefinition {
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
static isEqual(def1: NoteLinkDefinition, def2: NoteLinkDefinition): boolean {
|
||||
return (
|
||||
def1.label === def2.label &&
|
||||
def1.url === def2.url &&
|
||||
def1.title === def2.title
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface Tag {
|
||||
@@ -52,9 +95,6 @@ export interface Resource {
|
||||
tags: Tag[];
|
||||
aliases: Alias[];
|
||||
links: ResourceLink[];
|
||||
|
||||
// TODO to remove
|
||||
definitions: NoteLinkDefinition[];
|
||||
}
|
||||
|
||||
export interface ResourceParser {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -7,13 +7,16 @@ describe('Foam URI', () => {
|
||||
describe('URI parsing', () => {
|
||||
const base = URI.file('/path/to/file.md');
|
||||
test.each([
|
||||
['https://www.google.com', URI.parse('https://www.google.com')],
|
||||
['/path/to/a/file.md', URI.parse('file:///path/to/a/file.md')],
|
||||
['../relative/file.md', URI.parse('file:///path/relative/file.md')],
|
||||
['https://www.google.com', URI.parse('https://www.google.com', 'file')],
|
||||
['/path/to/a/file.md', URI.parse('file:///path/to/a/file.md', 'file')],
|
||||
[
|
||||
'../relative/file.md',
|
||||
URI.parse('file:///path/relative/file.md', 'file'),
|
||||
],
|
||||
['#section', base.with({ fragment: 'section' })],
|
||||
[
|
||||
'../relative/file.md#section',
|
||||
URI.parse('file:/path/relative/file.md#section'),
|
||||
URI.parse('file:///path/relative/file.md#section', 'file'),
|
||||
],
|
||||
])('URI Parsing (%s)', (input, exp) => {
|
||||
const result = base.resolve(input);
|
||||
@@ -25,8 +28,8 @@ describe('Foam URI', () => {
|
||||
});
|
||||
|
||||
it('normalizes the Windows drive letter to upper case', () => {
|
||||
const upperCase = URI.parse('file:///C:/this/is/a/Path');
|
||||
const lowerCase = URI.parse('file:///c:/this/is/a/Path');
|
||||
const upperCase = URI.parse('file:///C:/this/is/a/Path', 'file');
|
||||
const lowerCase = URI.parse('file:///c:/this/is/a/Path', 'file');
|
||||
expect(upperCase.path).toEqual('/C:/this/is/a/Path');
|
||||
expect(lowerCase.path).toEqual('/C:/this/is/a/Path');
|
||||
expect(upperCase.toFsPath()).toEqual('C:\\this\\is\\a\\Path');
|
||||
@@ -35,11 +38,11 @@ describe('Foam URI', () => {
|
||||
|
||||
it('consistently parses file paths', () => {
|
||||
const win1 = URI.file('c:\\this\\is\\a\\path');
|
||||
const win2 = URI.parse('c:\\this\\is\\a\\path');
|
||||
const win2 = URI.parse('c:\\this\\is\\a\\path', 'file');
|
||||
expect(win1).toEqual(win2);
|
||||
|
||||
const unix1 = URI.file('/this/is/a/path');
|
||||
const unix2 = URI.parse('/this/is/a/path');
|
||||
const unix2 = URI.parse('/this/is/a/path', 'file');
|
||||
expect(unix1).toEqual(unix2);
|
||||
});
|
||||
|
||||
@@ -125,30 +128,132 @@ describe('asAbsoluteUri', () => {
|
||||
).toEqual(workspaceFolder2.joinPath('file'));
|
||||
});
|
||||
|
||||
describe('with Windows filesystem paths', () => {
|
||||
it('should return the given path if it is a Windows absolute path (C: drive)', () => {
|
||||
const windowsPath = 'C:/Users/user/template.md';
|
||||
const workspaceFolder = URI.file('/workspace/folder');
|
||||
const result = asAbsoluteUri(windowsPath, [workspaceFolder]);
|
||||
// Should convert to proper URI format
|
||||
expect(result.path).toEqual('C:/Users/user/template.md');
|
||||
describe('forceSubfolder parameter', () => {
|
||||
it('should return the URI as-is when it is already a subfolder of a base folder', () => {
|
||||
const absolutePath = '/workspace/subfolder/file.md';
|
||||
const baseFolder = URI.file('/workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
expect(result.path).toEqual('/workspace/subfolder/file.md');
|
||||
});
|
||||
|
||||
it('should return the given path if it is a Windows absolute path (backslashes)', () => {
|
||||
const windowsPath = 'C:\\Users\\user\\template.md';
|
||||
const workspaceFolder = URI.file('/workspace/folder');
|
||||
const result = asAbsoluteUri(windowsPath, [workspaceFolder]);
|
||||
// Should convert to proper URI format
|
||||
expect(result.path).toEqual('C:\\Users\\user\\template.md');
|
||||
it('should force URI to be a subfolder when forceSubfolder is true and URI is not a subfolder', () => {
|
||||
const absolutePath = '/other/path/file.md';
|
||||
const baseFolder = URI.file('/workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
expect(result.path).toEqual('/workspace/other/path/file.md');
|
||||
});
|
||||
|
||||
it('should treat relative Windows-style paths as relative', () => {
|
||||
const relativePath = 'folder\\subfolder\\file.md';
|
||||
const workspaceFolder = URI.file('/workspace/folder');
|
||||
const result = asAbsoluteUri(relativePath, [workspaceFolder]);
|
||||
expect(result.path).toEqual(
|
||||
'/workspace/folder/folder\\subfolder\\file.md'
|
||||
it('should use case-sensitive path comparison when checking if URI is already a subfolder', () => {
|
||||
const absolutePath = '/Workspace/subfolder/file.md'; // Different case
|
||||
const baseFolder = URI.file('/workspace'); // lowercase
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should be forced to subfolder because case-sensitive comparison fails
|
||||
expect(result.path).toEqual('/workspace/Workspace/subfolder/file.md');
|
||||
});
|
||||
|
||||
it('should not force subfolder when URI is exactly a case-sensitive match', () => {
|
||||
const absolutePath = '/workspace/subfolder/file.md';
|
||||
const baseFolder = URI.file('/workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should not be forced because it's already a subfolder (case matches)
|
||||
expect(result.path).toEqual('/workspace/subfolder/file.md');
|
||||
});
|
||||
|
||||
it('should handle multiple base folders when checking subfolder status', () => {
|
||||
const absolutePath = '/project2/subfolder/file.md';
|
||||
const baseFolder1 = URI.file('/project1');
|
||||
const baseFolder2 = URI.file('/project2');
|
||||
const result = asAbsoluteUri(
|
||||
absolutePath,
|
||||
[baseFolder1, baseFolder2],
|
||||
true
|
||||
);
|
||||
|
||||
// Should not be forced because it's already a subfolder of baseFolder2
|
||||
expect(result.path).toEqual('/project2/subfolder/file.md');
|
||||
});
|
||||
|
||||
describe('Windows paths', () => {
|
||||
it('should return the Windows URI as-is when it is already a subfolder of a base folder', () => {
|
||||
const absolutePath = 'C:\\workspace\\subfolder\\file.md';
|
||||
const baseFolder = URI.file('C:\\workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
expect(result.toFsPath()).toEqual('C:\\workspace\\subfolder\\file.md');
|
||||
});
|
||||
|
||||
it('should force Windows URI to be a subfolder when forceSubfolder is true and URI is not a subfolder', () => {
|
||||
const absolutePath = 'D:\\other\\path\\file.md';
|
||||
const baseFolder = URI.file('C:\\workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
expect(result.toFsPath()).toEqual(
|
||||
'C:\\workspace\\D:\\other\\path\\file.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('should use case-insensitive path comparison for Windows paths when checking if URI is already a subfolder', () => {
|
||||
const absolutePath = 'C:\\Workspace\\subfolder\\file.md'; // Different case
|
||||
const baseFolder = URI.file('C:\\workspace'); // lowercase
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should be forced to subfolder because case-sensitive comparison fails
|
||||
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
|
||||
});
|
||||
|
||||
it('should not force Windows subfolder when URI is exactly a case-sensitive match', () => {
|
||||
const absolutePath = 'C:\\workspace\\subfolder\\file.md';
|
||||
const baseFolder = URI.file('C:\\workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should not be forced because it's already a subfolder (case matches)
|
||||
expect(result.toFsPath()).toEqual('C:\\workspace\\subfolder\\file.md');
|
||||
});
|
||||
|
||||
it('should handle different drive letters as non-subfolders', () => {
|
||||
const absolutePath = 'D:\\workspace\\subfolder\\file.md'; // Different drive
|
||||
const baseFolder = URI.file('C:\\workspace'); // Same path, different drive
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should be forced because different drives are not subfolders
|
||||
expect(result.toFsPath()).toEqual(
|
||||
'C:\\workspace\\D:\\workspace\\subfolder\\file.md'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle Windows backslash paths in case-sensitive comparison', () => {
|
||||
const absolutePath = 'C:\\Workspace\\subfolder\\file.md'; // Different case with backslashes
|
||||
const baseFolder = URI.file('c:\\Workspace'); // lowercase with backslashes
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should be forced to subfolder because case-sensitive comparison fails
|
||||
// Note: Drive letters are normalized to uppercase by URI.file()
|
||||
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
|
||||
});
|
||||
|
||||
it('should handle Windows backslash paths in case-sensitive comparison - reverse', () => {
|
||||
const absolutePath = 'c:\\Workspace\\subfolder\\file.md'; // Different case with backslashes
|
||||
const baseFolder = URI.file('C:\\Workspace'); // lowercase with backslashes
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
// Should be forced to subfolder because case-sensitive comparison fails
|
||||
// Note: Drive letters are normalized to uppercase by URI.file()
|
||||
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
|
||||
});
|
||||
|
||||
it('should handle forward slash absolute path also with windows base folders', () => {
|
||||
// Using this format for the path works on both windows and unix
|
||||
// and allows using absolute paths relative to the workspace root
|
||||
const absolutePath = '/subfolder/file.md';
|
||||
const baseFolder = URI.file('C:\\Workspace');
|
||||
const result = asAbsoluteUri(absolutePath, [baseFolder], true);
|
||||
|
||||
expect(result.toFsPath()).toEqual('C:\\Workspace\\subfolder\\file.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// See LICENSE for details
|
||||
|
||||
import { CharCode } from '../common/charCode';
|
||||
import { isNone } from '../utils';
|
||||
import * as pathUtils from '../utils/path';
|
||||
|
||||
/**
|
||||
@@ -44,13 +45,31 @@ export class URI {
|
||||
this.fragment = from.fragment ?? _empty;
|
||||
}
|
||||
|
||||
static parse(value: string): URI {
|
||||
/**
|
||||
* Parses a string value into a URI object.
|
||||
* @param value the string value of the URI
|
||||
* @param defaultScheme the default scheme to use if none is provided in the value.
|
||||
* - if a `string`, it will be used as the default scheme
|
||||
* - if a `URI`, its scheme will be used as the default scheme
|
||||
* - if `null`, no default scheme should be used (which forces `value` to have a scheme)
|
||||
* @returns the parsed URI object
|
||||
* @throws if no scheme is provided in value and no default scheme is given
|
||||
*/
|
||||
static parse(value: string, defaultScheme: URI | string | null): URI {
|
||||
const match = _regexp.exec(value);
|
||||
if (!match) {
|
||||
return new URI();
|
||||
}
|
||||
defaultScheme =
|
||||
defaultScheme instanceof URI
|
||||
? defaultScheme.scheme
|
||||
: (defaultScheme as string | null);
|
||||
const scheme = match[2] || defaultScheme;
|
||||
if (isNone(scheme)) {
|
||||
throw new Error(`Invalid URI: The URI scheme is missing: ${value}`);
|
||||
}
|
||||
return new URI({
|
||||
scheme: match[2] || 'file',
|
||||
scheme,
|
||||
authority: percentDecode(match[4] ?? _empty),
|
||||
path: pathUtils.fromFsPath(percentDecode(match[5] ?? _empty))[0],
|
||||
query: percentDecode(match[7] ?? _empty),
|
||||
@@ -73,7 +92,7 @@ export class URI {
|
||||
}
|
||||
|
||||
resolve(value: string | URI, isDirectory = false): URI {
|
||||
const uri = value instanceof URI ? value : URI.parse(value);
|
||||
const uri = value instanceof URI ? value : URI.parse(value, 'file');
|
||||
if (!uri.isAbsolute()) {
|
||||
if (uri.scheme === 'file' || uri.scheme === 'placeholder') {
|
||||
let newUri = this.with({ fragment: uri.fragment });
|
||||
@@ -124,6 +143,15 @@ export class URI {
|
||||
return new URI({ ...this, path });
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new URI with the specified changes.
|
||||
* Note that this does not validate the resulting URI, e.g. you can
|
||||
* set the path to a relative path.
|
||||
* If you want to ensure that the path is properly formatted, use `forPath` instead.
|
||||
*
|
||||
* @param change an object that describes the desired changes to the URI.
|
||||
* @returns a new URI instance with the updated fields
|
||||
*/
|
||||
with(change: {
|
||||
scheme?: string;
|
||||
authority?: string;
|
||||
@@ -140,6 +168,20 @@ export class URI {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new URI with the specified path.
|
||||
* The difference between `with({ path })` and `forPath(path)` is that
|
||||
* this function will ensure that the path is properly formatted (e.g. starting with a `/`)
|
||||
* whereas `with` will take the path "as is".
|
||||
*
|
||||
* @param path the new path
|
||||
* @returns a new URI instance with the updated path
|
||||
*/
|
||||
forPath(path: string): URI {
|
||||
const formattedPath = pathUtils.fromFsPath(percentDecode(path))[0];
|
||||
return new URI({ ...this, path: formattedPath });
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a URI without the fragment and query information
|
||||
*/
|
||||
@@ -412,10 +454,13 @@ export function asAbsoluteUri(
|
||||
const isDrivePath = /^[a-zA-Z]:/.test(path);
|
||||
// Check if this is already a POSIX absolute path
|
||||
if (path.startsWith('/') || isDrivePath) {
|
||||
const uri = baseFolders[0].with({ path });
|
||||
const uri = baseFolders[0].forPath(path); // Validate the path
|
||||
|
||||
if (forceSubfolder) {
|
||||
const isAlreadySubfolder = baseFolders.some(folder =>
|
||||
uri.path.startsWith(folder.path)
|
||||
isDrivePath
|
||||
? uri.path.toLowerCase().startsWith(folder.path.toLowerCase())
|
||||
: uri.path.startsWith(folder.path)
|
||||
);
|
||||
if (!isAlreadySubfolder) {
|
||||
return baseFolders[0].joinPath(uri.path);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -26,7 +26,6 @@ const asResource = (uri: URI): Resource => {
|
||||
sections: [],
|
||||
links: [],
|
||||
tags: [],
|
||||
definitions: [],
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -94,8 +94,15 @@ export class FileListBasedMatcher implements IMatcher {
|
||||
include: string[];
|
||||
exclude: string[];
|
||||
|
||||
constructor(files: URI[], private readonly listFiles: () => Promise<URI[]>) {
|
||||
constructor(
|
||||
files: URI[],
|
||||
private readonly listFiles: () => Promise<URI[]>,
|
||||
include: string[] = ['**/*'],
|
||||
exclude: string[] = []
|
||||
) {
|
||||
this.files = files.map(f => f.path);
|
||||
this.include = include;
|
||||
this.exclude = exclude;
|
||||
}
|
||||
|
||||
match(files: URI[]): URI[] {
|
||||
@@ -110,9 +117,13 @@ export class FileListBasedMatcher implements IMatcher {
|
||||
this.files = (await this.listFiles()).map(f => f.path);
|
||||
}
|
||||
|
||||
static async createFromListFn(listFiles: () => Promise<URI[]>) {
|
||||
static async createFromListFn(
|
||||
listFiles: () => Promise<URI[]>,
|
||||
include: string[] = ['**/*'],
|
||||
exclude: string[] = []
|
||||
) {
|
||||
const files = await listFiles();
|
||||
return new FileListBasedMatcher(files, listFiles);
|
||||
return new FileListBasedMatcher(files, listFiles, include, exclude);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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(
|
||||
@@ -435,4 +523,101 @@ describe('MarkdownLink', () => {
|
||||
expect(wikilinkEdit.range).toEqual(wikilink.range);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parse links with resolved definitions', () => {
|
||||
it('should parse wikilink with resolved definition - target and section from definition, alias from rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[my-note|Custom Display Text]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'my-note',
|
||||
url: './docs/document.md#introduction',
|
||||
title: 'Document Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('introduction'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('Custom Display Text'); // From rawText
|
||||
});
|
||||
|
||||
it('should parse reference-style link with resolved definition - target and section from definition, alias from rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'link',
|
||||
rawText: '[Click here to read][myref]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'myref',
|
||||
url: './document.md#section',
|
||||
title: 'My Document',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./document.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('Click here to read'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle wikilink with resolved definition but no section in URL', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[my-note#ignored-section|Display Text]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'my-note',
|
||||
url: './docs/document.md', // No fragment
|
||||
title: 'Document Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./docs/document.md'); // From definition.url
|
||||
expect(parsed.section).toEqual(''); // Empty - no fragment in definition.url
|
||||
expect(parsed.alias).toEqual('Display Text'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle reference-style link with resolved definition but no alias in rawText', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'link',
|
||||
rawText: '[text][ref]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'ref',
|
||||
url: './target.md#section',
|
||||
title: 'Target',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('./target.md'); // From definition.url (base)
|
||||
expect(parsed.section).toEqual('section'); // From definition.url (fragment)
|
||||
expect(parsed.alias).toEqual('text'); // From rawText
|
||||
});
|
||||
|
||||
it('should handle complex URLs in definitions', () => {
|
||||
const link: ResourceLink = {
|
||||
type: 'wikilink',
|
||||
rawText: '[[note|Alias]]',
|
||||
range: Range.create(0, 0),
|
||||
isEmbed: false,
|
||||
definition: {
|
||||
label: 'note',
|
||||
url: '../path/to/some file.md#complex section name',
|
||||
title: 'Title',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = MarkdownLink.analyzeLink(link);
|
||||
expect(parsed.target).toEqual('../path/to/some file.md'); // Base path
|
||||
expect(parsed.section).toEqual('complex section name'); // Fragment with spaces
|
||||
expect(parsed.alias).toEqual('Alias'); // From rawText
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ResourceLink } from '../model/note';
|
||||
import { URI } from '../model/uri';
|
||||
import { TextEdit } from './text-edit';
|
||||
|
||||
export abstract class MarkdownLink {
|
||||
@@ -6,7 +7,7 @@ export abstract class MarkdownLink {
|
||||
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
|
||||
);
|
||||
private static directLinkRegex = new RegExp(
|
||||
/\[(.*)\]\(<?([^#>]*)?#?([^\]>]+)?>?\)/
|
||||
/\[(.*)\]\(<?([^#>]*?)(?:#([^>\s"'()]*))?(?:\s+(?:"[^"]*"|'[^']*'))?>?\)/
|
||||
);
|
||||
|
||||
public static analyzeLink(link: ResourceLink) {
|
||||
@@ -15,6 +16,17 @@ export abstract class MarkdownLink {
|
||||
const [, target, section, alias] = this.wikilinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
|
||||
// For wikilinks with resolved definitions, parse target and section from definition URL
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
const definitionUri = URI.parse(link.definition.url, 'tmp');
|
||||
return {
|
||||
target: definitionUri.path, // Base path from definition
|
||||
section: definitionUri.fragment, // Fragment from definition
|
||||
alias: alias ?? '', // Alias from rawText
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
target: target?.replace(/\\/g, '') ?? '',
|
||||
section: section ?? '',
|
||||
@@ -22,9 +34,34 @@ export abstract class MarkdownLink {
|
||||
};
|
||||
}
|
||||
if (link.type === 'link') {
|
||||
const [, alias, target, section] = this.directLinkRegex.exec(
|
||||
link.rawText
|
||||
);
|
||||
// For reference-style links with resolved definitions, parse target and section from definition URL
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
// Extract alias from rawText for reference-style links
|
||||
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
|
||||
const alias = referenceMatch ? referenceMatch[1] : '';
|
||||
|
||||
// Parse target and section from definition URL
|
||||
const definitionUri = URI.parse(link.definition.url, 'tmp');
|
||||
return {
|
||||
target: definitionUri.path, // Base path from definition
|
||||
section: definitionUri.fragment, // Fragment from definition
|
||||
alias: alias, // Alias from rawText
|
||||
};
|
||||
}
|
||||
|
||||
const match = this.directLinkRegex.exec(link.rawText);
|
||||
if (!match) {
|
||||
// This might be a reference-style link that wasn't resolved
|
||||
// Try to extract just the alias text for reference-style links
|
||||
const referenceMatch = /^\[([^\]]*)\]/.exec(link.rawText);
|
||||
const alias = referenceMatch ? referenceMatch[1] : '';
|
||||
return {
|
||||
target: '',
|
||||
section: '',
|
||||
alias: alias,
|
||||
};
|
||||
}
|
||||
const [, alias, target, section] = match;
|
||||
return {
|
||||
target: target ?? '',
|
||||
section: section ?? '',
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
getBlockFor,
|
||||
ParserPlugin,
|
||||
} from './markdown-parser';
|
||||
import { NoteLinkDefinition, ResourceLink } from '../model/note';
|
||||
import { Logger } from '../utils/log';
|
||||
import { URI } from '../model/uri';
|
||||
import { Range } from '../model/range';
|
||||
@@ -102,6 +103,17 @@ describe('Markdown parsing', () => {
|
||||
expect(link.isEmbed).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should set reference to alias for wikilinks with alias', () => {
|
||||
const note = createNoteFromMarkdown(
|
||||
'This is a [[target-file|Display Name]] wikilink.'
|
||||
);
|
||||
expect(note.links.length).toEqual(1);
|
||||
const link = note.links[0];
|
||||
expect(link.type).toEqual('wikilink');
|
||||
expect(ResourceLink.isUnresolvedReference(link)).toBe(true);
|
||||
expect(link.definition).toEqual('target-file');
|
||||
});
|
||||
|
||||
it('should skip wikilinks in codeblocks', () => {
|
||||
const noteA = createNoteFromMarkdown(`
|
||||
this is some text with our [[first-wikilink]].
|
||||
@@ -131,6 +143,84 @@ this is some text with our [[second-wikilink]].
|
||||
'[[second-wikilink]]',
|
||||
]);
|
||||
});
|
||||
|
||||
it('#1545 - should not detect single brackets as links', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
"She said [winning the award] was her best year."
|
||||
|
||||
We use brackets ([ and ]) to surround links.
|
||||
|
||||
This is not an easy task.[^1]
|
||||
|
||||
[^1]: It would be easier if more papers were well written.
|
||||
`);
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should detect reference-style links', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
# Test Document
|
||||
|
||||
This is a [reference-style link][ref1] and another [link][ref2].
|
||||
|
||||
[ref1]: target1.md "Target 1"
|
||||
[ref2]: target2.md "Target 2"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(2);
|
||||
|
||||
const link1 = note.links[0];
|
||||
expect(link1.type).toEqual('link');
|
||||
expect(link1.rawText).toEqual('[reference-style link][ref1]');
|
||||
expect(ResourceLink.isResolvedReference(link1)).toBe(true);
|
||||
const definition1 = link1.definition as NoteLinkDefinition;
|
||||
expect(definition1.label).toEqual('ref1');
|
||||
expect(definition1.url).toEqual('target1.md');
|
||||
expect(definition1.title).toEqual('Target 1');
|
||||
|
||||
const link2 = note.links[1];
|
||||
expect(link2.type).toEqual('link');
|
||||
expect(link2.rawText).toEqual('[link][ref2]');
|
||||
expect(ResourceLink.isResolvedReference(link2)).toBe(true);
|
||||
const definition2 = link2.definition as NoteLinkDefinition;
|
||||
expect(definition2.label).toEqual('ref2');
|
||||
expect(definition2.url).toEqual('target2.md');
|
||||
});
|
||||
|
||||
it('should handle reference-style links without matching definitions', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
This is a [reference-style link][missing-ref].
|
||||
|
||||
[existing-ref]: target.md "Target"
|
||||
`);
|
||||
|
||||
// Per CommonMark spec, reference links without matching definitions
|
||||
// should be treated as plain text, not as links
|
||||
expect(note.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
it('should handle mixed link types', () => {
|
||||
const note = createNoteFromMarkdown(`
|
||||
This has [[wikilink]], [inline link](target.md), and [reference link][ref].
|
||||
|
||||
[ref]: reference-target.md "Reference Target"
|
||||
`);
|
||||
|
||||
expect(note.links.length).toEqual(3);
|
||||
|
||||
expect(note.links[0].type).toEqual('wikilink');
|
||||
expect(note.links[0].rawText).toEqual('[[wikilink]]');
|
||||
expect(ResourceLink.isUnresolvedReference(note.links[0])).toBe(true);
|
||||
expect(note.links[0].definition).toEqual('wikilink');
|
||||
|
||||
expect(note.links[1].type).toEqual('link');
|
||||
expect(note.links[1].rawText).toEqual('[inline link](target.md)');
|
||||
expect(ResourceLink.isReferenceStyleLink(note.links[1])).toBe(false);
|
||||
|
||||
expect(note.links[2].type).toEqual('link');
|
||||
expect(note.links[2].rawText).toEqual('[reference link][ref]');
|
||||
expect(ResourceLink.isResolvedReference(note.links[2])).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Note Title', () => {
|
||||
|
||||
@@ -6,7 +6,12 @@ import wikiLinkPlugin from 'remark-wiki-link';
|
||||
import frontmatterPlugin from 'remark-frontmatter';
|
||||
import { parse as parseYAML } from 'yaml';
|
||||
import visit from 'unist-util-visit';
|
||||
import { NoteLinkDefinition, Resource, ResourceParser } from '../model/note';
|
||||
import {
|
||||
NoteLinkDefinition,
|
||||
Resource,
|
||||
ResourceLink,
|
||||
ResourceParser,
|
||||
} from '../model/note';
|
||||
import { Position } from '../model/position';
|
||||
import { Range } from '../model/range';
|
||||
import { extractHashtags, extractTagsFromProp, hash, isSome } from '../utils';
|
||||
@@ -41,19 +46,34 @@ export interface ParserCacheEntry {
|
||||
*/
|
||||
export type ParserCache = ICache<URI, ParserCacheEntry>;
|
||||
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin, { aliasDivider: '|' });
|
||||
|
||||
export function getLinkDefinitions(markdown: string): NoteLinkDefinition[] {
|
||||
const definitions: NoteLinkDefinition[] = [];
|
||||
const tree = parser.parse(markdown);
|
||||
visit(tree, node => {
|
||||
if (node.type === 'definition') {
|
||||
definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
});
|
||||
return definitions;
|
||||
}
|
||||
|
||||
export function createMarkdownParser(
|
||||
extraPlugins: ParserPlugin[] = [],
|
||||
cache?: ParserCache
|
||||
): ResourceParser {
|
||||
const parser = unified()
|
||||
.use(markdownParse, { gfm: true })
|
||||
.use(frontmatterPlugin, ['yaml'])
|
||||
.use(wikiLinkPlugin, { aliasDivider: '|' });
|
||||
|
||||
const plugins = [
|
||||
titlePlugin,
|
||||
wikilinkPlugin,
|
||||
definitionsPlugin,
|
||||
tagsPlugin,
|
||||
aliasesPlugin,
|
||||
sectionsPlugin,
|
||||
@@ -89,9 +109,10 @@ export function createMarkdownParser(
|
||||
tags: [],
|
||||
aliases: [],
|
||||
links: [],
|
||||
definitions: [],
|
||||
};
|
||||
|
||||
const localDefinitions: NoteLinkDefinition[] = [];
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.onWillVisitTree?.(tree, note);
|
||||
@@ -119,6 +140,15 @@ export function createMarkdownParser(
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === 'definition') {
|
||||
localDefinitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
});
|
||||
}
|
||||
|
||||
for (const plugin of plugins) {
|
||||
try {
|
||||
plugin.visit?.(node, note, markdown);
|
||||
@@ -134,6 +164,29 @@ export function createMarkdownParser(
|
||||
handleError(plugin, 'onDidVisitTree', uri, e);
|
||||
}
|
||||
}
|
||||
|
||||
// Post-processing: Resolve reference identifiers to definitions for all links
|
||||
note.links.forEach(link => {
|
||||
if (ResourceLink.isUnresolvedReference(link)) {
|
||||
// This link has a reference identifier (from linkReference or wikilink)
|
||||
const referenceId = link.definition;
|
||||
const definition = localDefinitions.find(
|
||||
def => def.label === referenceId
|
||||
);
|
||||
|
||||
// Set definition to definition object if found, otherwise keep as string
|
||||
(link as any).definition = definition || referenceId;
|
||||
}
|
||||
});
|
||||
|
||||
// For type: 'link', keep only if:
|
||||
// - It's a direct link [text](url) - no definition field
|
||||
// - It's a resolved reference - definition is an object
|
||||
note.links = note.links.filter(
|
||||
link =>
|
||||
link.type === 'wikilink' || !ResourceLink.isUnresolvedReference(link)
|
||||
);
|
||||
|
||||
Logger.debug('Result:', note);
|
||||
return note;
|
||||
},
|
||||
@@ -359,6 +412,7 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
rawText: literalContent,
|
||||
range,
|
||||
isEmbed,
|
||||
definition: (node as any).value,
|
||||
});
|
||||
}
|
||||
if (node.type === 'link' || node.type === 'image') {
|
||||
@@ -378,24 +432,27 @@ const wikilinkPlugin: ParserPlugin = {
|
||||
isEmbed: literalContent.startsWith('!'),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
if (node.type === 'linkReference') {
|
||||
const literalContent = noteSource.substring(
|
||||
node.position!.start.offset!,
|
||||
node.position!.end.offset!
|
||||
);
|
||||
|
||||
const definitionsPlugin: ParserPlugin = {
|
||||
name: 'definitions',
|
||||
visit: (node, note) => {
|
||||
if (node.type === 'definition') {
|
||||
note.definitions.push({
|
||||
label: (node as any).label,
|
||||
url: (node as any).url,
|
||||
title: (node as any).title,
|
||||
const identifier = (node as any).identifier;
|
||||
|
||||
note.links.push({
|
||||
type: 'link',
|
||||
rawText: literalContent,
|
||||
range: astPositionToFoamRange(node.position!),
|
||||
isEmbed: false,
|
||||
// Store reference identifier temporarily - will be resolved in onDidVisitTree
|
||||
definition: identifier,
|
||||
});
|
||||
}
|
||||
},
|
||||
onDidVisitTree: (tree, note) => {
|
||||
const end = astPointToFoamPosition(tree.position.end);
|
||||
note.definitions = getFoamDefinitions(note.definitions, end);
|
||||
// This onDidVisitTree is now handled globally after all plugins have run
|
||||
// and localDefinitions have been collected.
|
||||
},
|
||||
};
|
||||
|
||||
@@ -414,31 +471,6 @@ const handleError = (
|
||||
);
|
||||
};
|
||||
|
||||
function getFoamDefinitions(
|
||||
defs: NoteLinkDefinition[],
|
||||
fileEndPoint: Position
|
||||
): NoteLinkDefinition[] {
|
||||
let previousLine = fileEndPoint.line;
|
||||
const foamDefinitions = [];
|
||||
|
||||
// walk through each definition in reverse order
|
||||
// (last one first)
|
||||
for (const def of defs.reverse()) {
|
||||
// if this definition is more than 2 lines above the
|
||||
// previous one below it (or file end), that means we
|
||||
// have exited the trailing definition block, and should bail
|
||||
const start = def.range!.start.line;
|
||||
if (start < previousLine - 2) {
|
||||
break;
|
||||
}
|
||||
|
||||
foamDefinitions.unshift(def);
|
||||
previousLine = def.range!.end.line;
|
||||
}
|
||||
|
||||
return foamDefinitions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the 1-index Point object into the VS Code 0-index Position object
|
||||
* @param point ast Point (1-indexed)
|
||||
|
||||
@@ -97,11 +97,7 @@ describe('Link resolution', () => {
|
||||
const ws = createTestWorkspace();
|
||||
const noteA = createTestNote({
|
||||
uri: '/somewhere/from/page-a.md',
|
||||
links: [{ slug: 'page-b' }],
|
||||
});
|
||||
noteA.definitions.push({
|
||||
label: 'page-b',
|
||||
url: '../to/page-b.md',
|
||||
links: [{ slug: 'page-b', definitionUrl: '../to/page-b.md' }],
|
||||
});
|
||||
const noteB = createTestNote({
|
||||
uri: '/somewhere/to/page-b.md',
|
||||
@@ -307,7 +303,104 @@ describe('Link resolution', () => {
|
||||
|
||||
expect(ws.resolveLink(noteB, noteB.links[0])).toEqual(noteA.uri);
|
||||
expect(ws.resolveLink(noteC, noteC.links[0])).toEqual(noteA.uri);
|
||||
expect(noteD.links).toEqual([]);
|
||||
// noteD has malformed URL with unencoded space, which gets treated as
|
||||
// shortcut reference [note] without definition, now correctly filtered out
|
||||
expect(noteD.links.length).toEqual(0);
|
||||
});
|
||||
|
||||
describe('Workspace-relative paths (root-path relative)', () => {
|
||||
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) {
|
||||
@@ -56,15 +57,8 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
const { target, section } = MarkdownLink.analyzeLink(link);
|
||||
switch (link.type) {
|
||||
case 'wikilink': {
|
||||
let definitionUri = undefined;
|
||||
for (const def of resource.definitions) {
|
||||
if (def.label === target) {
|
||||
definitionUri = def.url;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (isSome(definitionUri)) {
|
||||
const definedUri = resource.uri.resolve(definitionUri);
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
const definedUri = resource.uri.resolve(link.definition.url);
|
||||
targetUri =
|
||||
workspace.find(definedUri, resource.uri)?.uri ??
|
||||
URI.placeholder(definedUri.path);
|
||||
@@ -74,24 +68,68 @@ export class MarkdownResourceProvider implements ResourceProvider {
|
||||
? resource.uri
|
||||
: workspace.find(target, resource.uri)?.uri ??
|
||||
URI.placeholder(target);
|
||||
|
||||
if (section) {
|
||||
targetUri = targetUri.with({ fragment: section });
|
||||
}
|
||||
}
|
||||
if (section) {
|
||||
targetUri = targetUri.with({ fragment: section });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'link': {
|
||||
// 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);
|
||||
if (ResourceLink.isUnresolvedReference(link)) {
|
||||
// Reference-style link with unresolved reference - treat as placeholder
|
||||
targetUri = URI.placeholder(link.definition);
|
||||
break;
|
||||
}
|
||||
|
||||
// Handle reference-style links first
|
||||
const targetPath = ResourceLink.isResolvedReference(link)
|
||||
? link.definition.url
|
||||
: target;
|
||||
|
||||
let path: string;
|
||||
let foundResource: Resource | null = null;
|
||||
|
||||
if (targetPath.startsWith('/')) {
|
||||
// Handle workspace-relative paths (root-path relative)
|
||||
if (this.workspaceRoots.length > 0) {
|
||||
// Try to resolve against each workspace root
|
||||
for (const workspaceRoot of this.workspaceRoots) {
|
||||
const candidatePath = targetPath.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 = targetPath.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 = targetPath;
|
||||
targetUri =
|
||||
workspace.find(path, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(path).path);
|
||||
}
|
||||
} else {
|
||||
// Handle relative paths and non-root paths
|
||||
path =
|
||||
targetPath.startsWith('./') || targetPath.startsWith('../')
|
||||
? targetPath
|
||||
: './' + targetPath;
|
||||
targetUri =
|
||||
workspace.find(path, resource.uri)?.uri ??
|
||||
URI.placeholder(resource.uri.resolve(path).path);
|
||||
}
|
||||
|
||||
if (section && !targetUri.isPlaceholder()) {
|
||||
targetUri = targetUri.with({ fragment: section });
|
||||
}
|
||||
@@ -114,8 +152,12 @@ export function createMarkdownReferences(
|
||||
const resource = source instanceof URI ? workspace.find(source) : source;
|
||||
|
||||
const definitions = resource.links
|
||||
.filter(link => link.type === 'wikilink')
|
||||
.filter(link => ResourceLink.isReferenceStyleLink(link))
|
||||
.map(link => {
|
||||
if (ResourceLink.isResolvedReference(link)) {
|
||||
return link.definition;
|
||||
}
|
||||
|
||||
const targetUri = workspace.resolveLink(resource, link);
|
||||
const target = workspace.find(targetUri);
|
||||
if (isNone(target)) {
|
||||
|
||||
34
packages/foam-vscode/src/core/services/progress.ts
Normal file
34
packages/foam-vscode/src/core/services/progress.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* Generic progress information for long-running operations
|
||||
*/
|
||||
export interface Progress<T = unknown> {
|
||||
/** Current item being processed (1-indexed) */
|
||||
current: number;
|
||||
/** Total number of items to process */
|
||||
total: number;
|
||||
/** Optional context data about the current item */
|
||||
context?: T;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback for reporting progress during operations
|
||||
*/
|
||||
export type ProgressCallback<T = unknown> = (progress: Progress<T>) => void;
|
||||
|
||||
/**
|
||||
* Cancellation token for aborting long-running operations
|
||||
*/
|
||||
export interface CancellationToken {
|
||||
/** Whether cancellation has been requested */
|
||||
readonly isCancellationRequested: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exception thrown when an operation is cancelled
|
||||
*/
|
||||
export class CancellationError extends Error {
|
||||
constructor(message: string = 'Operation cancelled') {
|
||||
super(message);
|
||||
this.name = 'CancellationError';
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -72,4 +72,32 @@ describe('applyTextEdit', () => {
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
|
||||
it('should apply multiple TextEdits in reverse order (VS Code behavior)', () => {
|
||||
// This test shows why reverse order is important for range stability
|
||||
const textEdits = [
|
||||
// Edit near beginning - would affect later ranges if applied first
|
||||
{
|
||||
newText: `[PREFIX] `,
|
||||
range: Range.create(0, 0, 0, 0),
|
||||
},
|
||||
// Edit in middle - range stays valid with reverse order
|
||||
{
|
||||
newText: `[MIDDLE] `,
|
||||
range: Range.create(0, 11, 0, 11),
|
||||
},
|
||||
// Edit at end - applied first, doesn't affect other ranges
|
||||
{
|
||||
newText: ` [END]`,
|
||||
range: Range.create(0, 15, 0, 15),
|
||||
},
|
||||
];
|
||||
|
||||
const text = `this is my text`;
|
||||
const expected = `[PREFIX] this is my [MIDDLE] text [END]`;
|
||||
|
||||
const actual = TextEdit.apply(text, textEdits);
|
||||
|
||||
expect(actual).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -14,7 +15,28 @@ export abstract class TextEdit {
|
||||
* @param textEdit
|
||||
* @returns {string} text with the applied textEdit
|
||||
*/
|
||||
public static apply(text: string, textEdit: TextEdit): string {
|
||||
public static apply(text: string, textEdit: TextEdit): string;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
public static apply(text: string, textEdits: TextEdit[]): string;
|
||||
// eslint-disable-next-line no-dupe-class-members
|
||||
public static apply(
|
||||
text: string,
|
||||
textEditOrEdits: TextEdit | TextEdit[]
|
||||
): string {
|
||||
if (Array.isArray(textEditOrEdits)) {
|
||||
// Apply edits in reverse order (end-to-beginning) to maintain range validity
|
||||
// This matches VS Code's behavior for TextEdit application
|
||||
const sortedEdits = [...textEditOrEdits].sort((a, b) =>
|
||||
Position.compareTo(b.range.start, a.range.start)
|
||||
);
|
||||
let result = text;
|
||||
for (const textEdit of sortedEdits) {
|
||||
result = this.apply(result, textEdit);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const textEdit = textEditOrEdits;
|
||||
const eol = detectNewline.graceful(text);
|
||||
const lines = text.split(eol);
|
||||
const characters = text.split('');
|
||||
@@ -42,3 +64,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;
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ export async function firstFrom<T>(
|
||||
* @param functions - The array of functions to execute.
|
||||
* @returns A generator yielding the results of the functions.
|
||||
*/
|
||||
function* lazyExecutor<T>(functions: Array<() => T>): Generator<T> {
|
||||
export function* lazyExecutor<T>(functions: Array<() => T>): Generator<T> {
|
||||
for (const fn of functions) {
|
||||
yield fn();
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { isSome } from './core';
|
||||
export const HASHTAG_REGEX =
|
||||
/(?<=^|\s)#([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
|
||||
const WORD_REGEX =
|
||||
/(?<=^|\s)([0-9]*[\p{L}\p{Emoji_Presentation}/_-][\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/gmu;
|
||||
/(?<=^|\s)#([0-9]*[\p{L}\p{Extended_Pictographic}/_-](?:[\p{L}\p{Extended_Pictographic}\p{N}/_-]|\uFE0F|\p{Emoji_Modifier})*)/gmu;
|
||||
export const WORD_REGEX =
|
||||
/(?<=^|\s)([0-9]*[\p{L}\p{Extended_Pictographic}/_-](?:[\p{L}\p{Extended_Pictographic}\p{N}/_-]|\uFE0F|\p{Emoji_Modifier})*)/gmu;
|
||||
|
||||
export const extractHashtags = (
|
||||
text: string
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
import { asAbsolutePaths } from './path';
|
||||
import { asAbsolutePaths, fromFsPath } from './path';
|
||||
|
||||
describe('path utils', () => {
|
||||
describe('fromFsPath', () => {
|
||||
it('should normalize backslashes in relative paths', () => {
|
||||
const [path] = fromFsPath('areas\\dailies\\2024\\file.md');
|
||||
expect(path).toBe('areas/dailies/2024/file.md');
|
||||
});
|
||||
|
||||
it('should handle mixed separators in relative paths', () => {
|
||||
const [path] = fromFsPath('areas/dailies\\2024/file.md');
|
||||
expect(path).toBe('areas/dailies/2024/file.md');
|
||||
});
|
||||
|
||||
it('should preserve forward slashes in relative paths', () => {
|
||||
const [path] = fromFsPath('areas/dailies/2024/file.md');
|
||||
expect(path).toBe('areas/dailies/2024/file.md');
|
||||
});
|
||||
|
||||
it('should normalize backslashes in Windows absolute paths', () => {
|
||||
const [path] = fromFsPath('C:\\workspace\\file.md');
|
||||
expect(path).toBe('/C:/workspace/file.md');
|
||||
});
|
||||
});
|
||||
|
||||
describe('asAbsolutePaths', () => {
|
||||
it('returns the path if already absolute', () => {
|
||||
const paths = asAbsolutePaths('/path/to/test', [
|
||||
|
||||
@@ -16,13 +16,16 @@ export function fromFsPath(path: string): [string, string] {
|
||||
let authority: string;
|
||||
if (isUNCShare(path)) {
|
||||
[path, authority] = parseUNCShare(path);
|
||||
path = path.replace(/\\/g, '/');
|
||||
} else if (hasDrive(path)) {
|
||||
path = '/' + path[0].toUpperCase() + path.substr(1).replace(/\\/g, '/');
|
||||
path = '/' + path[0].toUpperCase() + path.substr(1);
|
||||
} else if (path[0] === '/' && hasDrive(path, 1)) {
|
||||
// POSIX representation of a Windows path: just normalize drive letter case
|
||||
path = '/' + path[1].toUpperCase() + path.substr(2);
|
||||
}
|
||||
|
||||
// Always normalize backslashes to forward slashes (filesystem → POSIX)
|
||||
path = path.replace(/\\/g, '/');
|
||||
|
||||
return [path, authority];
|
||||
}
|
||||
|
||||
|
||||
306
packages/foam-vscode/src/core/utils/task-deduplicator.test.ts
Normal file
306
packages/foam-vscode/src/core/utils/task-deduplicator.test.ts
Normal file
@@ -0,0 +1,306 @@
|
||||
import { TaskDeduplicator } from './task-deduplicator';
|
||||
|
||||
describe('TaskDeduplicator', () => {
|
||||
describe('run', () => {
|
||||
it('should execute a task and return its result', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task = jest.fn(async () => 'result');
|
||||
|
||||
const result = await deduplicator.run(task);
|
||||
|
||||
expect(result).toBe('result');
|
||||
expect(task).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should deduplicate concurrent calls to the same task', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
let executeCount = 0;
|
||||
|
||||
const task = async () => {
|
||||
executeCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return 'result';
|
||||
};
|
||||
|
||||
// Start multiple concurrent calls
|
||||
const [result1, result2, result3] = await Promise.all([
|
||||
deduplicator.run(task),
|
||||
deduplicator.run(task),
|
||||
deduplicator.run(task),
|
||||
]);
|
||||
|
||||
// All should get the same result
|
||||
expect(result1).toBe('result');
|
||||
expect(result2).toBe('result');
|
||||
expect(result3).toBe('result');
|
||||
|
||||
// Task should only execute once
|
||||
expect(executeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('should call onDuplicate callback for concurrent calls', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const onDuplicate = jest.fn();
|
||||
|
||||
const task = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return 'result';
|
||||
};
|
||||
|
||||
// Start concurrent calls
|
||||
const promise1 = deduplicator.run(task);
|
||||
const promise2 = deduplicator.run(task, onDuplicate);
|
||||
const promise3 = deduplicator.run(task, onDuplicate);
|
||||
|
||||
await Promise.all([promise1, promise2, promise3]);
|
||||
|
||||
// onDuplicate should be called for the 2nd and 3rd calls
|
||||
expect(onDuplicate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should not call onDuplicate for the first call', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const onDuplicate = jest.fn();
|
||||
const task = jest.fn(async () => 'result');
|
||||
|
||||
await deduplicator.run(task, onDuplicate);
|
||||
|
||||
expect(onDuplicate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should allow new tasks after previous task completes', async () => {
|
||||
const deduplicator = new TaskDeduplicator<number>();
|
||||
let counter = 0;
|
||||
|
||||
const task1 = async () => ++counter;
|
||||
const task2 = async () => ++counter;
|
||||
|
||||
const result1 = await deduplicator.run(task1);
|
||||
const result2 = await deduplicator.run(task2);
|
||||
|
||||
expect(result1).toBe(1);
|
||||
expect(result2).toBe(2);
|
||||
});
|
||||
|
||||
it('should propagate errors from the task', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const error = new Error('Task failed');
|
||||
const task = jest.fn(async () => {
|
||||
throw error;
|
||||
});
|
||||
|
||||
await expect(deduplicator.run(task)).rejects.toThrow('Task failed');
|
||||
});
|
||||
|
||||
it('should propagate errors to all concurrent callers', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const error = new Error('Task failed');
|
||||
|
||||
const task = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
throw error;
|
||||
};
|
||||
|
||||
const promise1 = deduplicator.run(task);
|
||||
const promise2 = deduplicator.run(task);
|
||||
const promise3 = deduplicator.run(task);
|
||||
|
||||
await expect(promise1).rejects.toThrow('Task failed');
|
||||
await expect(promise2).rejects.toThrow('Task failed');
|
||||
await expect(promise3).rejects.toThrow('Task failed');
|
||||
});
|
||||
|
||||
it('should clear running task after error', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task1 = jest.fn(async () => {
|
||||
throw new Error('Task failed');
|
||||
});
|
||||
const task2 = jest.fn(async () => 'success');
|
||||
|
||||
// First task fails
|
||||
await expect(deduplicator.run(task1)).rejects.toThrow('Task failed');
|
||||
|
||||
// Second task should execute (not deduplicated)
|
||||
const result = await deduplicator.run(task2);
|
||||
|
||||
expect(result).toBe('success');
|
||||
expect(task1).toHaveBeenCalledTimes(1);
|
||||
expect(task2).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should handle different return types', async () => {
|
||||
// String
|
||||
const stringDeduplicator = new TaskDeduplicator<string>();
|
||||
const stringResult = await stringDeduplicator.run(async () => 'test');
|
||||
expect(stringResult).toBe('test');
|
||||
|
||||
// Number
|
||||
const numberDeduplicator = new TaskDeduplicator<number>();
|
||||
const numberResult = await numberDeduplicator.run(async () => 42);
|
||||
expect(numberResult).toBe(42);
|
||||
|
||||
// Object
|
||||
const objectDeduplicator = new TaskDeduplicator<{ value: string }>();
|
||||
const objectResult = await objectDeduplicator.run(async () => ({
|
||||
value: 'test',
|
||||
}));
|
||||
expect(objectResult).toEqual({ value: 'test' });
|
||||
|
||||
// Union types
|
||||
type Status = 'complete' | 'cancelled' | 'error';
|
||||
const statusDeduplicator = new TaskDeduplicator<Status>();
|
||||
const statusResult = await statusDeduplicator.run(
|
||||
async () => 'complete' as Status
|
||||
);
|
||||
expect(statusResult).toBe('complete');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isRunning', () => {
|
||||
it('should return false when no task is running', () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when a task is running', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
|
||||
const task = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return 'result';
|
||||
};
|
||||
|
||||
const promise = deduplicator.run(task);
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(true);
|
||||
|
||||
await promise;
|
||||
});
|
||||
|
||||
it('should return false after task completes', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task = jest.fn(async () => 'result');
|
||||
|
||||
await deduplicator.run(task);
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false after task fails', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task = jest.fn(async () => {
|
||||
throw new Error('Failed');
|
||||
});
|
||||
|
||||
await expect(deduplicator.run(task)).rejects.toThrow('Failed');
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clear', () => {
|
||||
it('should clear the running task reference', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
|
||||
const task = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return 'result';
|
||||
};
|
||||
|
||||
const promise = deduplicator.run(task);
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(true);
|
||||
|
||||
deduplicator.clear();
|
||||
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
|
||||
// Original promise should still complete
|
||||
await expect(promise).resolves.toBe('result');
|
||||
});
|
||||
|
||||
it('should allow new task after manual clear', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
let executeCount = 0;
|
||||
|
||||
const task = async () => {
|
||||
executeCount++;
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
return 'result';
|
||||
};
|
||||
|
||||
// Start first task
|
||||
const promise1 = deduplicator.run(task);
|
||||
|
||||
// Clear while still running
|
||||
deduplicator.clear();
|
||||
|
||||
// Start second task (should not be deduplicated)
|
||||
const promise2 = deduplicator.run(task);
|
||||
|
||||
await Promise.all([promise1, promise2]);
|
||||
|
||||
// Both tasks should have executed
|
||||
expect(executeCount).toBe(2);
|
||||
});
|
||||
|
||||
it('should be safe to call when no task is running', () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
|
||||
expect(() => deduplicator.clear()).not.toThrow();
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('should handle tasks that resolve immediately', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task = jest.fn(async () => 'immediate');
|
||||
|
||||
const result = await deduplicator.run(task);
|
||||
|
||||
expect(result).toBe('immediate');
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle tasks that throw synchronously', async () => {
|
||||
const deduplicator = new TaskDeduplicator<string>();
|
||||
const task = jest.fn(() => {
|
||||
throw new Error('Sync error');
|
||||
});
|
||||
|
||||
await expect(deduplicator.run(task as any)).rejects.toThrow('Sync error');
|
||||
expect(deduplicator.isRunning()).toBe(false);
|
||||
});
|
||||
|
||||
it('should handle null/undefined results', async () => {
|
||||
const nullDeduplicator = new TaskDeduplicator<null>();
|
||||
const nullResult = await nullDeduplicator.run(async () => null);
|
||||
expect(nullResult).toBeNull();
|
||||
|
||||
const undefinedDeduplicator = new TaskDeduplicator<undefined>();
|
||||
const undefinedResult = await undefinedDeduplicator.run(
|
||||
async () => undefined
|
||||
);
|
||||
expect(undefinedResult).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle sequential calls with delays between them', async () => {
|
||||
const deduplicator = new TaskDeduplicator<number>();
|
||||
let counter = 0;
|
||||
|
||||
const task = async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 10));
|
||||
return ++counter;
|
||||
};
|
||||
|
||||
const result1 = await deduplicator.run(task);
|
||||
await new Promise(resolve => setTimeout(resolve, 20));
|
||||
const result2 = await deduplicator.run(task);
|
||||
|
||||
expect(result1).toBe(1);
|
||||
expect(result2).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
67
packages/foam-vscode/src/core/utils/task-deduplicator.ts
Normal file
67
packages/foam-vscode/src/core/utils/task-deduplicator.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* A utility class for deduplicating concurrent async operations.
|
||||
* When multiple calls are made while a task is running, subsequent calls
|
||||
* will wait for and receive the result of the already-running task instead
|
||||
* of starting a new one.
|
||||
*
|
||||
* @example
|
||||
* const deduplicator = new TaskDeduplicator<string>();
|
||||
*
|
||||
* async function expensiveOperation(input: string): Promise<string> {
|
||||
* return deduplicator.run(async () => {
|
||||
* // Expensive work here
|
||||
* return result;
|
||||
* });
|
||||
* }
|
||||
*
|
||||
* // Multiple concurrent calls will share the same execution
|
||||
* const [result1, result2] = await Promise.all([
|
||||
* expensiveOperation("test"),
|
||||
* expensiveOperation("test"),
|
||||
* ]);
|
||||
* // Only runs once, both get the same result
|
||||
*/
|
||||
export class TaskDeduplicator<T> {
|
||||
private runningTask: Promise<T> | null = null;
|
||||
|
||||
/**
|
||||
* Run a task with deduplication.
|
||||
* If a task is already running, waits for it to complete and returns its result.
|
||||
* Otherwise, starts the task and stores its promise for other callers to await.
|
||||
*
|
||||
* @param task The async function to execute
|
||||
* @param onDuplicate Optional callback when a duplicate call is detected
|
||||
* @returns The result of the task
|
||||
*/
|
||||
async run(task: () => Promise<T>, onDuplicate?: () => void): Promise<T> {
|
||||
// If already running, wait for the existing task
|
||||
if (this.runningTask) {
|
||||
onDuplicate?.();
|
||||
return await this.runningTask;
|
||||
}
|
||||
|
||||
// Start the task and store the promise
|
||||
this.runningTask = task();
|
||||
|
||||
try {
|
||||
return await this.runningTask;
|
||||
} finally {
|
||||
// Clear the task when done
|
||||
this.runningTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a task is currently running
|
||||
*/
|
||||
isRunning(): boolean {
|
||||
return this.runningTask !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the running task reference (useful for testing or error recovery)
|
||||
*/
|
||||
clear(): void {
|
||||
this.runningTask = null;
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,25 @@ describe('hashtag extraction', () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it('supports emoji tags with variant selectors (issue #1536)', () => {
|
||||
expect(
|
||||
extractHashtags('#🗃️/37-Education #🔖/37/Learning #🟣HOUSE #🟠MONEY').map(
|
||||
t => t.label
|
||||
)
|
||||
).toEqual(['🗃️/37-Education', '🔖/37/Learning', '🟣HOUSE', '🟠MONEY']);
|
||||
});
|
||||
|
||||
it('supports individual emojis with variant selectors', () => {
|
||||
// Test each emoji separately to debug
|
||||
expect(extractHashtags('#🗃️').map(t => t.label)).toEqual(['🗃️']);
|
||||
expect(extractHashtags('#🔖').map(t => t.label)).toEqual(['🔖']);
|
||||
});
|
||||
|
||||
it('supports emojis that work without variant selector', () => {
|
||||
// These emojis should work with current implementation
|
||||
expect(extractHashtags('#📥 #⭐').map(t => t.label)).toEqual(['📥', '⭐']);
|
||||
});
|
||||
|
||||
it('ignores hashes in plain text urls and links', () => {
|
||||
expect(
|
||||
extractHashtags(`
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
/* @unit-ready */
|
||||
import { workspace } from 'vscode';
|
||||
import { createDailyNoteIfNotExists, getDailyNoteUri } from './dated-notes';
|
||||
import { workspace, window } from 'vscode';
|
||||
import {
|
||||
CREATE_DAILY_NOTE_WARNING_RESPONSE,
|
||||
createDailyNoteIfNotExists,
|
||||
getDailyNoteUri,
|
||||
} from './dated-notes';
|
||||
import { isWindows } from './core/common/platform';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
deleteFile,
|
||||
getUriInWorkspace,
|
||||
showInEditor,
|
||||
withModifiedFoamConfiguration,
|
||||
} from './test/test-utils-vscode';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { URI } from './core/model/uri';
|
||||
import { fileExists } from './services/editor';
|
||||
import { getDailyNoteTemplateUri } from './services/templates';
|
||||
import { fileExists, readFile } from './services/editor';
|
||||
import {
|
||||
getDailyNoteTemplateCandidateUris,
|
||||
getDailyNoteTemplateUri,
|
||||
} from './services/templates';
|
||||
|
||||
describe('getDailyNoteUri', () => {
|
||||
const date = new Date('2021-02-07T00:00:00Z');
|
||||
@@ -52,6 +57,15 @@ describe('getDailyNoteUri', () => {
|
||||
describe('Daily note creation and template processing', () => {
|
||||
const DAILY_NOTE_TEMPLATE = ['.foam', 'templates', 'daily-note.md'];
|
||||
|
||||
beforeEach(async () => {
|
||||
// Ensure daily note template are removed before each test
|
||||
for (const template of getDailyNoteTemplateCandidateUris()) {
|
||||
if (await fileExists(template)) {
|
||||
await deleteFile(template);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Basic daily note creation', () => {
|
||||
it('Creates a new daily note when it does not exist', async () => {
|
||||
const targetDate = new Date(2021, 8, 1);
|
||||
@@ -86,20 +100,10 @@ describe('Daily note creation and template processing', () => {
|
||||
});
|
||||
|
||||
describe('Template variable resolution', () => {
|
||||
beforeEach(async () => {
|
||||
// Ensure no template exists
|
||||
let i = 0;
|
||||
while ((await fileExists(await getDailyNoteTemplateUri())) && i < 5) {
|
||||
await deleteFile(await getDailyNoteTemplateUri());
|
||||
i++;
|
||||
}
|
||||
});
|
||||
|
||||
it('Resolves all FOAM_DATE_* variables correctly', async () => {
|
||||
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})
|
||||
@@ -107,6 +111,7 @@ Month: \${FOAM_DATE_MONTH} (name: \${FOAM_DATE_MONTH_NAME}, short: \${FOAM_DATE_
|
||||
Date: \${FOAM_DATE_DATE}
|
||||
Day: \${FOAM_DATE_DAY_NAME} (short: \${FOAM_DATE_DAY_NAME_SHORT})
|
||||
Week: \${FOAM_DATE_WEEK}
|
||||
Week Year: \${FOAM_DATE_WEEK_YEAR}
|
||||
Unix: \${FOAM_DATE_SECONDS_UNIX}`,
|
||||
DAILY_NOTE_TEMPLATE
|
||||
);
|
||||
@@ -123,7 +128,7 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
|
||||
expect(content).toContain('Date: 12');
|
||||
expect(content).toContain('Day: Sunday (short: Sun)');
|
||||
expect(content).toContain('Week: 36');
|
||||
expect(content).toContain('Unix: 1631404800');
|
||||
expect(content).toContain('Week Year: 2021');
|
||||
|
||||
await deleteFile(template.uri);
|
||||
await deleteFile(result.uri);
|
||||
@@ -242,7 +247,7 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
|
||||
const monthName = foamDate.toLocaleString('default', { month: 'long' });
|
||||
const day = foamDate.getDate();
|
||||
return {
|
||||
filepath: \`/\${foamDate.getFullYear()}-\${String(foamDate.getMonth() + 1).padStart(2, '0')}-\${String(day).padStart(2, '0')}.md\`,
|
||||
filepath: \`\${foamDate.getFullYear()}-\${String(foamDate.getMonth() + 1).padStart(2, '0')}-\${String(day).padStart(2, '0')}.md\`,
|
||||
content: \`# JS Template: \${monthName} \${day}\n\nGenerated by JavaScript template.\`
|
||||
};
|
||||
};`,
|
||||
@@ -272,6 +277,36 @@ Unix: \${FOAM_DATE_SECONDS_UNIX}`,
|
||||
expect(content).toContain('# 2021-09-21'); // Should use fallback text with formatted date
|
||||
});
|
||||
|
||||
it('prompts to create a daily note template if one does not exist', async () => {
|
||||
const targetDate = new Date(2021, 8, 23);
|
||||
const foam = {} as any;
|
||||
|
||||
expect(await getDailyNoteTemplateUri()).not.toBeDefined();
|
||||
|
||||
// Intercept the showWarningMessage call
|
||||
const showWarningMessageSpy = jest
|
||||
.spyOn(window, 'showWarningMessage')
|
||||
.mockResolvedValue(CREATE_DAILY_NOTE_WARNING_RESPONSE as any); // simulate user action
|
||||
|
||||
await createDailyNoteIfNotExists(targetDate, foam);
|
||||
|
||||
expect(showWarningMessageSpy.mock.calls[0][0]).toMatch(
|
||||
/No daily note template found/
|
||||
);
|
||||
|
||||
const templateUri = await getDailyNoteTemplateUri();
|
||||
|
||||
expect(templateUri).toBeDefined();
|
||||
expect(await fileExists(templateUri)).toBe(true);
|
||||
|
||||
const templateContent = await readFile(templateUri);
|
||||
expect(templateContent).toContain('foam_template:');
|
||||
|
||||
// Clean up the created template
|
||||
await deleteFile(templateUri);
|
||||
showWarningMessageSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('Processes template frontmatter metadata correctly', async () => {
|
||||
const targetDate = new Date(2021, 8, 22);
|
||||
|
||||
@@ -306,6 +341,67 @@ author: foam
|
||||
});
|
||||
});
|
||||
|
||||
describe('Issue #1499 - Double template application with absolute paths', () => {
|
||||
it('should not apply template twice when reopening existing daily note with absolute filepath template', async () => {
|
||||
const targetDate = new Date(2021, 8, 25);
|
||||
const TEMPLATE_WITH_ABSOLUTE_FILEPATH = `---
|
||||
foam_template:
|
||||
name: Daily note
|
||||
description: Daily note template
|
||||
filepath: '/\${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}.md'
|
||||
---
|
||||
|
||||
# \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE} - DAILY NOTE
|
||||
|
||||
Daily content here.`;
|
||||
|
||||
// Create the template with absolute filepath
|
||||
const template = await createFile(
|
||||
TEMPLATE_WITH_ABSOLUTE_FILEPATH,
|
||||
DAILY_NOTE_TEMPLATE
|
||||
);
|
||||
|
||||
const uri = getDailyNoteUri(targetDate);
|
||||
const foam = {} as any; // Mock Foam instance
|
||||
|
||||
// First call: Create the daily note
|
||||
const result1 = await createDailyNoteIfNotExists(targetDate, foam);
|
||||
expect(result1.didCreateFile).toBe(true);
|
||||
|
||||
const doc1 = await showInEditor(uri);
|
||||
const content1 = doc1.editor.document.getText();
|
||||
expect(content1).toContain('# 2021-09-25 - DAILY NOTE');
|
||||
expect(content1).toContain('Daily content here.');
|
||||
|
||||
// Count how many times the template content appears (should be once)
|
||||
const templateOccurrences1 = (
|
||||
content1.match(/# 2021-09-25 - DAILY NOTE/g) || []
|
||||
).length;
|
||||
expect(templateOccurrences1).toBe(1);
|
||||
|
||||
await closeEditors();
|
||||
|
||||
// Second call: Open existing daily note (this should NOT apply template again)
|
||||
const result2 = await createDailyNoteIfNotExists(targetDate, foam);
|
||||
expect(result2.didCreateFile).toBe(false); // File already exists
|
||||
|
||||
const doc2 = await showInEditor(uri);
|
||||
const content2 = doc2.editor.document.getText();
|
||||
|
||||
// Verify template is NOT applied twice
|
||||
const templateOccurrences2 = (
|
||||
content2.match(/# 2021-09-25 - DAILY NOTE/g) || []
|
||||
).length;
|
||||
expect(templateOccurrences2).toBe(1); // Should still be 1, not 2
|
||||
|
||||
// Content should be identical to first time
|
||||
expect(content2).toEqual(content1);
|
||||
|
||||
await deleteFile(template.uri);
|
||||
await deleteFile(result1.uri);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanWorkspace();
|
||||
await closeEditors();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Uri, window, workspace } from 'vscode';
|
||||
import { joinPath } from './core/utils/path';
|
||||
import dateFormat from 'dateformat';
|
||||
import { URI } from './core/model/uri';
|
||||
@@ -6,6 +7,8 @@ import { getFoamVsCodeConfig } from './services/config';
|
||||
import { asAbsoluteWorkspaceUri, focusNote } from './services/editor';
|
||||
import { Foam } from './core/model/foam';
|
||||
import { createNote } from './features/commands/create-note';
|
||||
import { fromVsCodeUri } from './utils/vsc-utils';
|
||||
import { showInEditor } from './test/test-utils-vscode';
|
||||
|
||||
/**
|
||||
* Open the daily note file.
|
||||
@@ -68,6 +71,30 @@ export function getDailyNoteFileName(date: Date): string {
|
||||
return `${dateFormat(date, filenameFormat, false)}.${fileExtension}`;
|
||||
}
|
||||
|
||||
const DEFAULT_DAILY_NOTE_TEMPLATE = `---
|
||||
foam_template:
|
||||
filepath: "/journal/\${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}.md"
|
||||
description: "Daily note template"
|
||||
---
|
||||
# \${FOAM_DATE_YEAR}-\${FOAM_DATE_MONTH}-\${FOAM_DATE_DATE}
|
||||
|
||||
> you probably want to delete these instructions as you customize your template
|
||||
|
||||
Welcome to your new daily note template.
|
||||
The file is located in \`.foam/templates/daily-note.md\`.
|
||||
The text in this file will be used as the content of your daily note.
|
||||
You can customize it as you like, and you can use the following variables in the template:
|
||||
- \`\${FOAM_DATE_YEAR}\`: The year of the date
|
||||
- \`\${FOAM_DATE_MONTH}\`: The month of the date
|
||||
- \`\${FOAM_DATE_DATE}\`: The day of the date
|
||||
- \`\${FOAM_TITLE}\`: The title of the note
|
||||
|
||||
Go to https://github.com/foambubble/foam/blob/main/docs/user/features/daily-notes.md for more details.
|
||||
For more complex templates, including Javascript dynamic templates, see https://github.com/foambubble/foam/blob/main/docs/user/features/note-templates.md.
|
||||
`;
|
||||
|
||||
export const CREATE_DAILY_NOTE_WARNING_RESPONSE = 'Create daily note template';
|
||||
|
||||
/**
|
||||
* Create a daily note using the unified creation engine (supports JS templates)
|
||||
*
|
||||
@@ -76,6 +103,38 @@ export function getDailyNoteFileName(date: Date): string {
|
||||
* @returns Whether the file was created and the URI
|
||||
*/
|
||||
export async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) {
|
||||
const templatePath = await getDailyNoteTemplateUri();
|
||||
|
||||
if (!templatePath) {
|
||||
window
|
||||
.showWarningMessage(
|
||||
'No daily note template found. Using legacy configuration (deprecated). Create a daily note template to avoid this warning and customize your daily note.',
|
||||
CREATE_DAILY_NOTE_WARNING_RESPONSE
|
||||
)
|
||||
.then(async action => {
|
||||
if (action === CREATE_DAILY_NOTE_WARNING_RESPONSE) {
|
||||
const newTemplateUri = Uri.joinPath(
|
||||
workspace.workspaceFolders[0].uri,
|
||||
'.foam',
|
||||
'templates',
|
||||
'daily-note.md'
|
||||
);
|
||||
await workspace.fs.writeFile(
|
||||
newTemplateUri,
|
||||
new TextEncoder().encode(DEFAULT_DAILY_NOTE_TEMPLATE)
|
||||
);
|
||||
await showInEditor(fromVsCodeUri(newTemplateUri));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Set up variables for template processing
|
||||
const formattedDate = dateFormat(targetDate, 'yyyy-mm-dd', false);
|
||||
const variables = {
|
||||
FOAM_TITLE: formattedDate,
|
||||
title: formattedDate,
|
||||
};
|
||||
|
||||
const dailyNoteUri = getDailyNoteUri(targetDate);
|
||||
const titleFormat: string =
|
||||
getFoamVsCodeConfig('openDailyNote.titleFormat') ??
|
||||
@@ -88,29 +147,15 @@ export async function createDailyNoteIfNotExists(targetDate: Date, foam: Foam) {
|
||||
false
|
||||
)}\n`;
|
||||
|
||||
const templatePath = await getDailyNoteTemplateUri();
|
||||
|
||||
// Set up variables for template processing
|
||||
const formattedDate = dateFormat(targetDate, 'yyyy-mm-dd', false);
|
||||
const variables = {
|
||||
FOAM_TITLE: formattedDate,
|
||||
title: formattedDate,
|
||||
};
|
||||
|
||||
// Format date without timezone conversion to avoid off-by-one errors
|
||||
const year = targetDate.getFullYear();
|
||||
const month = String(targetDate.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(targetDate.getDate()).padStart(2, '0');
|
||||
const dateString = `${year}-${month}-${day}`;
|
||||
|
||||
return await createNote(
|
||||
{
|
||||
notePath: dailyNoteUri.toFsPath(),
|
||||
templatePath: templatePath,
|
||||
text: templateFallbackText, // fallback if template doesn't exist
|
||||
date: dateString, // YYYY-MM-DD format without timezone issues
|
||||
text: templateFallbackText,
|
||||
date: targetDate,
|
||||
variables: variables,
|
||||
onFileExists: 'open', // existing behavior - open if exists
|
||||
onFileExists: 'open',
|
||||
onRelativeNotePath: 'resolve-from-root',
|
||||
},
|
||||
foam
|
||||
);
|
||||
|
||||
@@ -4,12 +4,14 @@ 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';
|
||||
import {
|
||||
getAttachmentsExtensions,
|
||||
getIgnoredFilesSetting,
|
||||
getExcludedFilesSetting,
|
||||
getIncludeFilesSetting,
|
||||
getNotesExtensions,
|
||||
} from './settings';
|
||||
import { AttachmentResourceProvider } from './core/services/attachment-provider';
|
||||
@@ -17,6 +19,7 @@ import { VsCodeWatcher } from './services/watcher';
|
||||
import { createMarkdownParser } from './core/services/markdown-parser';
|
||||
import VsCodeBasedParserCache from './services/cache';
|
||||
import { createMatcherAndDataStore } from './services/editor';
|
||||
import { OllamaEmbeddingProvider } from './ai/providers/ollama/ollama-provider';
|
||||
|
||||
export async function activate(context: ExtensionContext) {
|
||||
const logger = new VsCodeOutputLogger();
|
||||
@@ -32,14 +35,15 @@ export async function activate(context: ExtensionContext) {
|
||||
}
|
||||
|
||||
// Prepare Foam
|
||||
const excludes = getIgnoredFilesSetting().map(g => g.toString());
|
||||
const { matcher, dataStore, excludePatterns } =
|
||||
await createMatcherAndDataStore(excludes);
|
||||
const includes = getIncludeFilesSetting().map(g => g.toString());
|
||||
const excludes = getExcludedFilesSetting().map(g => g.toString());
|
||||
const { matcher, dataStore, includePatterns, excludePatterns } =
|
||||
await createMatcherAndDataStore(includes, excludes);
|
||||
|
||||
Logger.info('Loading from directories:');
|
||||
for (const folder of workspace.workspaceFolders) {
|
||||
Logger.info('- ' + folder.uri.fsPath);
|
||||
Logger.info(' Include: **/*');
|
||||
Logger.info(' Include: ' + includePatterns.get(folder.name).join(','));
|
||||
Logger.info(' Exclude: ' + excludePatterns.get(folder.name).join(','));
|
||||
}
|
||||
|
||||
@@ -51,10 +55,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();
|
||||
@@ -62,13 +72,20 @@ export async function activate(context: ExtensionContext) {
|
||||
attachmentExtConfig
|
||||
);
|
||||
|
||||
// Initialize embedding provider
|
||||
const aiEnabled = workspace.getConfiguration('foam.experimental').get('ai');
|
||||
const embeddingProvider = aiEnabled
|
||||
? new OllamaEmbeddingProvider()
|
||||
: undefined;
|
||||
|
||||
const foamPromise = bootstrap(
|
||||
matcher,
|
||||
watcher,
|
||||
dataStore,
|
||||
parser,
|
||||
[markdownProvider, attachmentProvider],
|
||||
defaultExtension
|
||||
defaultExtension,
|
||||
embeddingProvider
|
||||
);
|
||||
|
||||
// Load the features
|
||||
@@ -91,6 +108,8 @@ export async function activate(context: ExtensionContext) {
|
||||
if (
|
||||
[
|
||||
'foam.files.ignore',
|
||||
'foam.files.exclude',
|
||||
'foam.files.include',
|
||||
'foam.files.attachmentExtensions',
|
||||
'foam.files.noteExtensions',
|
||||
'foam.files.defaultNoteExtension',
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
import { commands, ExtensionContext, window, workspace, Uri } from 'vscode';
|
||||
import { Foam } from '../../core/model/foam';
|
||||
import { FoamWorkspace } from '../../core/model/workspace';
|
||||
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
|
||||
import { ResourceParser } from '../../core/model/note';
|
||||
import { IMatcher } from '../../core/services/datastore';
|
||||
import { convertLinkFormat } from '../../core/janitor';
|
||||
import { isMdEditor } from '../../services/editor';
|
||||
|
||||
type LinkFormat = 'wikilink' | 'link';
|
||||
|
||||
enum ConvertOption {
|
||||
Wikilink2MDlink,
|
||||
MDlink2Wikilink,
|
||||
}
|
||||
|
||||
interface IConfig {
|
||||
from: string;
|
||||
to: string;
|
||||
}
|
||||
|
||||
const Config: { [key in ConvertOption]: IConfig } = {
|
||||
[ConvertOption.Wikilink2MDlink]: {
|
||||
from: 'wikilink',
|
||||
to: 'link',
|
||||
},
|
||||
[ConvertOption.MDlink2Wikilink]: {
|
||||
from: 'link',
|
||||
to: 'wikilink',
|
||||
},
|
||||
};
|
||||
|
||||
export default async function activate(
|
||||
context: ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
/*
|
||||
commands:
|
||||
foam-vscode.convert-link-style-inplace
|
||||
foam-vscode.convert-link-style-incopy
|
||||
*/
|
||||
context.subscriptions.push(
|
||||
commands.registerCommand('foam-vscode.convert-link-style-inplace', () => {
|
||||
return convertLinkAdapter(
|
||||
foam.workspace,
|
||||
foam.services.parser,
|
||||
foam.services.matcher,
|
||||
true
|
||||
);
|
||||
}),
|
||||
commands.registerCommand('foam-vscode.convert-link-style-incopy', () => {
|
||||
return convertLinkAdapter(
|
||||
foam.workspace,
|
||||
foam.services.parser,
|
||||
foam.services.matcher,
|
||||
false
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function convertLinkAdapter(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
isInPlace: boolean
|
||||
) {
|
||||
const convertOption = await pickConvertStrategy();
|
||||
if (!convertOption) {
|
||||
window.showInformationMessage('Convert canceled');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isInPlace) {
|
||||
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
|
||||
} else {
|
||||
await convertLinkInCopy(fWorkspace, fParser, fMatcher, convertOption);
|
||||
}
|
||||
}
|
||||
|
||||
async function pickConvertStrategy(): Promise<IConfig | undefined> {
|
||||
const options = {
|
||||
'to wikilink': ConvertOption.MDlink2Wikilink,
|
||||
'to markdown link': ConvertOption.Wikilink2MDlink,
|
||||
};
|
||||
return window.showQuickPick(Object.keys(options)).then(name => {
|
||||
if (name) {
|
||||
return Config[options[name]];
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* convert links based on its workspace and the note containing it.
|
||||
* Changes happen in-place
|
||||
* @param fWorkspace
|
||||
* @param fParser
|
||||
* @param fMatcher
|
||||
* @param convertOption
|
||||
* @returns void
|
||||
*/
|
||||
async function convertLinkInPlace(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
convertOption: IConfig
|
||||
) {
|
||||
const editor = window.activeTextEditor;
|
||||
const doc = editor.document;
|
||||
|
||||
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
|
||||
return;
|
||||
}
|
||||
// const eol = getEditorEOL();
|
||||
let text = doc.getText();
|
||||
|
||||
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
|
||||
|
||||
const textReplaceArr = resource.links
|
||||
.filter(link => link.type === convertOption.from)
|
||||
.map(link =>
|
||||
convertLinkFormat(
|
||||
link,
|
||||
convertOption.to as LinkFormat,
|
||||
fWorkspace,
|
||||
resource
|
||||
)
|
||||
)
|
||||
/* transform .range property into vscode range */
|
||||
.map(linkReplace => ({
|
||||
...linkReplace,
|
||||
range: toVsCodeRange(linkReplace.range),
|
||||
}));
|
||||
|
||||
/* reorder the array such that the later range comes first */
|
||||
textReplaceArr.sort((a, b) => b.range.start.compareTo(a.range.start));
|
||||
|
||||
await editor.edit(editorBuilder => {
|
||||
textReplaceArr.forEach(edit => {
|
||||
editorBuilder.replace(edit.range, edit.newText);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* convert links based on its workspace and the note containing it.
|
||||
* Changes happen in a copy
|
||||
* 1. prepare a copy file, and makt it the activeTextEditor
|
||||
* 2. call to convertLinkInPlace
|
||||
* @param fWorkspace
|
||||
* @param fParser
|
||||
* @param fMatcher
|
||||
* @param convertOption
|
||||
* @returns void
|
||||
*/
|
||||
async function convertLinkInCopy(
|
||||
fWorkspace: FoamWorkspace,
|
||||
fParser: ResourceParser,
|
||||
fMatcher: IMatcher,
|
||||
convertOption: IConfig
|
||||
) {
|
||||
const editor = window.activeTextEditor;
|
||||
const doc = editor.document;
|
||||
|
||||
if (!isMdEditor(editor) || !fMatcher.isMatch(fromVsCodeUri(doc.uri))) {
|
||||
return;
|
||||
}
|
||||
// const eol = getEditorEOL();
|
||||
let text = doc.getText();
|
||||
|
||||
const resource = fParser.parse(fromVsCodeUri(doc.uri), text);
|
||||
const basePath = doc.uri.path.split('/').slice(0, -1).join('/');
|
||||
|
||||
const fileUri = doc.uri.with({
|
||||
path: `${
|
||||
basePath ? basePath + '/' : ''
|
||||
}${resource.uri.getName()}.copy${resource.uri.getExtension()}`,
|
||||
});
|
||||
const encoder = new TextEncoder();
|
||||
await workspace.fs.writeFile(fileUri, encoder.encode(text));
|
||||
await window.showTextDocument(fileUri);
|
||||
|
||||
await convertLinkInPlace(fWorkspace, fParser, fMatcher, convertOption);
|
||||
}
|
||||
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal file
184
packages/foam-vscode/src/features/commands/convert-links.spec.ts
Normal file
@@ -0,0 +1,184 @@
|
||||
/* @unit-ready */
|
||||
|
||||
import * as vscode from 'vscode';
|
||||
import {
|
||||
cleanWorkspace,
|
||||
closeEditors,
|
||||
createFile,
|
||||
showInEditor,
|
||||
waitForNoteInFoamWorkspace,
|
||||
} from '../../test/test-utils-vscode';
|
||||
import { deleteFile } from '../../services/editor';
|
||||
import { Logger } from '../../core/utils/log';
|
||||
import {
|
||||
CONVERT_WIKILINK_TO_MDLINK,
|
||||
CONVERT_MDLINK_TO_WIKILINK,
|
||||
} from './convert-links';
|
||||
|
||||
Logger.setLevel('error');
|
||||
|
||||
describe('Link Conversion Commands', () => {
|
||||
beforeEach(async () => {
|
||||
await cleanWorkspace();
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanWorkspace();
|
||||
await closeEditors();
|
||||
});
|
||||
|
||||
describe('foam-vscode.convert-wikilink-to-markdown', () => {
|
||||
it('should convert wikilink to markdown link', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile('Text before [[note-a]] text after');
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
const result = editor.document.getText();
|
||||
expect(result).toBe('Text before [Note A](note-a.md) text after');
|
||||
|
||||
await deleteFile(noteA.uri);
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should position cursor at end of converted text', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile('Text before [[note-a]] text after');
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
// Cursor should be at the end of the converted markdown link
|
||||
const expectedPosition = 'Text before [Note A](note-a.md)'.length;
|
||||
expect(editor.selection.active).toEqual(
|
||||
new vscode.Position(0, expectedPosition)
|
||||
);
|
||||
|
||||
await deleteFile(noteA.uri);
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should show info message when no wikilink at cursor', async () => {
|
||||
const { uri } = await createFile('Text with no wikilinks');
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 5, 0, 5);
|
||||
|
||||
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
'No wikilink found at cursor position'
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
|
||||
it('should show error when resource not found', async () => {
|
||||
const { uri } = await createFile(
|
||||
'Text before [[nonexistent-file]] text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 20, 0, 20);
|
||||
|
||||
const showErrorSpy = jest
|
||||
.spyOn(vscode.window, 'showErrorMessage')
|
||||
.mockResolvedValue(undefined);
|
||||
|
||||
Logger.setLevel('off');
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
Logger.setLevel('error');
|
||||
|
||||
expect(showErrorSpy).toHaveBeenCalled();
|
||||
|
||||
showErrorSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('foam-vscode.convert-markdown-to-wikilink', () => {
|
||||
it('should convert markdown link to wikilink', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile(
|
||||
'Text before [Note A](note-a.md) text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
const result = editor.document.getText();
|
||||
expect(result).toBe('Text before [[note-a]] text after');
|
||||
|
||||
await deleteFile(uri);
|
||||
await deleteFile(noteA.uri);
|
||||
});
|
||||
|
||||
it('should position cursor at end of converted text', async () => {
|
||||
const noteA = await createFile('# Note A', ['note-a.md']);
|
||||
const { uri } = await createFile(
|
||||
'Text before [Note A](note-a.md) text after'
|
||||
);
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 15, 0, 15);
|
||||
await waitForNoteInFoamWorkspace(noteA.uri);
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
// Cursor should be at the end of the converted wikilink
|
||||
const expectedPosition = 'Text before [[note-a]]'.length;
|
||||
expect(editor.document.getText()).toBe(
|
||||
'Text before [[note-a]] text after'
|
||||
);
|
||||
expect(editor.selection.active).toEqual(
|
||||
new vscode.Position(0, expectedPosition)
|
||||
);
|
||||
|
||||
await deleteFile(uri);
|
||||
await deleteFile(noteA.uri);
|
||||
});
|
||||
|
||||
it('should show info message when no markdown link at cursor', async () => {
|
||||
const { uri } = await createFile('Text with no markdown links');
|
||||
const { editor } = await showInEditor(uri);
|
||||
|
||||
editor.selection = new vscode.Selection(0, 5, 0, 5);
|
||||
|
||||
const showInfoSpy = jest.spyOn(vscode.window, 'showInformationMessage');
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
expect(showInfoSpy).toHaveBeenCalledWith(
|
||||
'No markdown link found at cursor position'
|
||||
);
|
||||
|
||||
showInfoSpy.mockRestore();
|
||||
await deleteFile(uri);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Command registration', () => {
|
||||
it('should handle no active editor gracefully', async () => {
|
||||
await closeEditors();
|
||||
|
||||
await vscode.commands.executeCommand(CONVERT_WIKILINK_TO_MDLINK.command);
|
||||
await vscode.commands.executeCommand(CONVERT_MDLINK_TO_WIKILINK.command);
|
||||
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal file
271
packages/foam-vscode/src/features/commands/convert-links.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
convertWikilinkToMarkdownAtPosition,
|
||||
convertMarkdownToWikilinkAtPosition,
|
||||
} from './convert-links';
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { Position } from '../../core/model/position';
|
||||
import { Range } from '../../core/model/range';
|
||||
import { TextEdit } from '../../core/services/text-edit';
|
||||
import { createTestNote, createTestWorkspace } from '../../test/test-utils';
|
||||
import { createMarkdownParser } from '../../core/services/markdown-parser';
|
||||
|
||||
describe('Link Conversion Functions', () => {
|
||||
describe('convertWikilinkToMarkdownAtPosition', () => {
|
||||
it('should convert simple wikilink to markdown link', () => {
|
||||
const documentText = 'Text before [[note-a]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 }; // Inside [[note-a]]
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note A](note-a.md)');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 22));
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe('Text before [Note A](note-a.md) text after');
|
||||
});
|
||||
|
||||
it('should convert wikilink with alias to markdown link', () => {
|
||||
const documentText = 'Text before [[note-a|Custom Title]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Custom Title](note-a.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Custom Title](note-a.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle subfolders paths correctly', () => {
|
||||
const documentText = 'Text before [[path/to/note-b]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 20 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/path/to/note-b.md', title: 'Note B' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note B](path/to/note-b.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Note B](path/to/note-b.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle relative paths correctly', () => {
|
||||
const documentText = 'Text before [[note-b]] text after';
|
||||
const documentUri = URI.file('/test/sub1/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 20 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/sub2/note-b.md', title: 'Note B' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[Note B](../sub2/note-b.md)');
|
||||
|
||||
// Check the final result after applying the edit
|
||||
const finalText = TextEdit.apply(documentText, result!);
|
||||
expect(finalText).toBe(
|
||||
'Text before [Note B](../sub2/note-b.md) text after'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return null when no wikilink at cursor position', () => {
|
||||
const documentText = 'Text with no wikilink at cursor';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 5 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when target resource not found', () => {
|
||||
const documentText = 'Text before [[nonexistent]] text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace(); // Empty workspace
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
expect(() => {
|
||||
convertWikilinkToMarkdownAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
}).toThrow('Resource "nonexistent" not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('convertMarkdownToWikilinkAtPosition', () => {
|
||||
it('should convert simple markdown link to wikilink', () => {
|
||||
const documentText = 'Text before [Note A](note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a]]');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 31));
|
||||
});
|
||||
|
||||
it('should convert simple markdown link to other folder to wikilink', () => {
|
||||
const documentText = 'Text before [Note A](docs/note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/docs/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a]]');
|
||||
expect(result!.range).toEqual(Range.create(0, 12, 0, 36));
|
||||
});
|
||||
|
||||
it('should preserve alias when different from title', () => {
|
||||
const documentText = 'Text before [Custom Title](note-a.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace().set(
|
||||
createTestNote({ uri: '/test/note-a.md', title: 'Note A' })
|
||||
);
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.newText).toBe('[[note-a|Custom Title]]');
|
||||
});
|
||||
|
||||
it('should return null when no markdown link at cursor position', () => {
|
||||
const documentText = 'Text with no markdown link at cursor';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 5 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
const result = convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('should throw error when target resource not found', () => {
|
||||
const documentText = 'Text before [Link](nonexistent.md) text after';
|
||||
const documentUri = URI.file('/test/current.md');
|
||||
const linkPosition: Position = { line: 0, character: 15 };
|
||||
|
||||
const workspace = createTestWorkspace();
|
||||
const parser = createMarkdownParser();
|
||||
|
||||
expect(() => {
|
||||
convertMarkdownToWikilinkAtPosition(
|
||||
documentText,
|
||||
documentUri,
|
||||
linkPosition,
|
||||
workspace,
|
||||
parser
|
||||
);
|
||||
}).toThrow('Resource not found: /test/nonexistent.md');
|
||||
});
|
||||
});
|
||||
});
|
||||
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal file
247
packages/foam-vscode/src/features/commands/convert-links.ts
Normal file
@@ -0,0 +1,247 @@
|
||||
import * as vscode from 'vscode';
|
||||
import { Foam } from '../../core/model/foam';
|
||||
import { Resource, ResourceLink } from '../../core/model/note';
|
||||
import { MarkdownLink } from '../../core/services/markdown-link';
|
||||
import { Range } from '../../core/model/range';
|
||||
import { Position } from '../../core/model/position';
|
||||
import { URI } from '../../core/model/uri';
|
||||
import { fromVsCodeUri, toVsCodeRange } from '../../utils/vsc-utils';
|
||||
import { Logger } from '../../core/utils/log';
|
||||
import { TextEdit } from '../../core/services/text-edit';
|
||||
|
||||
export const CONVERT_WIKILINK_TO_MDLINK = {
|
||||
command: 'foam-vscode.convert-wikilink-to-mdlink',
|
||||
title: 'Foam: Convert Wikilink to Markdown Link',
|
||||
};
|
||||
|
||||
export const CONVERT_MDLINK_TO_WIKILINK = {
|
||||
command: 'foam-vscode.convert-mdlink-to-wikilink',
|
||||
title: 'Foam: Convert Markdown Link to Wikilink',
|
||||
};
|
||||
|
||||
/**
|
||||
* Pure function to convert a wikilink to markdown link at a specific position
|
||||
* Returns the TextEdit to apply, or null if no conversion is possible
|
||||
*/
|
||||
export function convertWikilinkToMarkdownAtPosition(
|
||||
documentText: string,
|
||||
documentUri: URI,
|
||||
linkPosition: Position,
|
||||
foamWorkspace: { find: (identifier: string) => Resource | null },
|
||||
foamParser: { parse: (uri: URI, text: string) => Resource }
|
||||
): TextEdit | null {
|
||||
// Parse the document to get all links using Foam's parser
|
||||
const resource = foamParser.parse(documentUri, documentText);
|
||||
|
||||
// Find the link at cursor position
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(
|
||||
link =>
|
||||
link.type === 'wikilink' &&
|
||||
Range.containsPosition(link.range, linkPosition)
|
||||
);
|
||||
|
||||
if (!targetLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the link to get target and alias information
|
||||
const linkInfo = MarkdownLink.analyzeLink(targetLink);
|
||||
|
||||
// Find the target resource in the workspace
|
||||
const targetResource = foamWorkspace.find(linkInfo.target);
|
||||
if (!targetResource) {
|
||||
throw new Error(`Resource "${linkInfo.target}" not found`);
|
||||
}
|
||||
|
||||
// Compute relative path from current file to target file
|
||||
const currentDirectory = documentUri.getDirectory();
|
||||
const relativePath = targetResource.uri.relativeTo(currentDirectory).path;
|
||||
|
||||
const alias = linkInfo.alias ? linkInfo.alias : targetResource.title;
|
||||
return MarkdownLink.createUpdateLinkEdit(targetLink, {
|
||||
type: 'link',
|
||||
target: relativePath,
|
||||
alias: alias,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function to convert a markdown link to wikilink at a specific position
|
||||
* Returns the TextEdit to apply, or null if no conversion is possible
|
||||
*/
|
||||
export function convertMarkdownToWikilinkAtPosition(
|
||||
documentText: string,
|
||||
documentUri: URI,
|
||||
cursorPosition: Position,
|
||||
foamWorkspace: {
|
||||
resolveLink: (resource: Resource, link: ResourceLink) => URI;
|
||||
get: (uri: URI) => Resource | null;
|
||||
getIdentifier: (uri: URI) => string;
|
||||
},
|
||||
foamParser: { parse: (uri: URI, text: string) => Resource }
|
||||
): TextEdit | null {
|
||||
// Parse the document to get all links using Foam's parser
|
||||
const resource = foamParser.parse(documentUri, documentText);
|
||||
|
||||
// Find the link at cursor position
|
||||
const targetLink: ResourceLink | undefined = resource.links.find(
|
||||
link =>
|
||||
link.type === 'link' && Range.containsPosition(link.range, cursorPosition)
|
||||
);
|
||||
|
||||
if (!targetLink) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse the link to get target and alias information
|
||||
const linkInfo = MarkdownLink.analyzeLink(targetLink);
|
||||
|
||||
// Try to resolve the target resource from the link
|
||||
const targetUri = foamWorkspace.resolveLink(resource, targetLink);
|
||||
const targetResource = foamWorkspace.get(targetUri);
|
||||
|
||||
if (!targetResource) {
|
||||
throw new Error(`Resource not found: ${targetUri.path}`);
|
||||
}
|
||||
|
||||
// Get the workspace identifier for the target resource
|
||||
const identifier = foamWorkspace.getIdentifier(targetResource.uri);
|
||||
|
||||
return MarkdownLink.createUpdateLinkEdit(targetLink, {
|
||||
type: 'wikilink',
|
||||
target: identifier,
|
||||
alias:
|
||||
linkInfo.alias && linkInfo.alias !== targetResource.title
|
||||
? linkInfo.alias
|
||||
: '',
|
||||
});
|
||||
}
|
||||
|
||||
export default async function activate(
|
||||
context: vscode.ExtensionContext,
|
||||
foamPromise: Promise<Foam>
|
||||
) {
|
||||
const foam = await foamPromise;
|
||||
|
||||
context.subscriptions.push(
|
||||
vscode.commands.registerCommand(CONVERT_WIKILINK_TO_MDLINK.command, () =>
|
||||
convertWikilinkToMarkdown(foam)
|
||||
),
|
||||
|
||||
vscode.commands.registerCommand(CONVERT_MDLINK_TO_WIKILINK.command, () =>
|
||||
convertMarkdownToWikilink(foam)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert wikilink at cursor position to markdown link format
|
||||
*/
|
||||
export async function convertWikilinkToMarkdown(foam: Foam): Promise<void> {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (!activeEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeEditor.document;
|
||||
const position = activeEditor.selection.active;
|
||||
|
||||
try {
|
||||
const edit = convertWikilinkToMarkdownAtPosition(
|
||||
document.getText(),
|
||||
fromVsCodeUri(document.uri),
|
||||
{
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
},
|
||||
foam.workspace,
|
||||
foam.services.parser
|
||||
);
|
||||
|
||||
if (!edit) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No wikilink found at cursor position'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the edit to the document
|
||||
const range = toVsCodeRange(edit.range);
|
||||
const success = await activeEditor.edit(editBuilder => {
|
||||
editBuilder.replace(range, edit.newText);
|
||||
});
|
||||
|
||||
// Position cursor at the end of the updated text
|
||||
if (success) {
|
||||
const newEndPosition = new vscode.Position(
|
||||
range.start.line,
|
||||
range.start.character + edit.newText.length
|
||||
);
|
||||
activeEditor.selection = new vscode.Selection(
|
||||
newEndPosition,
|
||||
newEndPosition
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to convert wikilink to markdown link', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to convert wikilink to markdown link: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown link at cursor position to wikilink format
|
||||
*/
|
||||
export async function convertMarkdownToWikilink(foam: Foam): Promise<void> {
|
||||
const activeEditor = vscode.window.activeTextEditor;
|
||||
if (!activeEditor) {
|
||||
return;
|
||||
}
|
||||
|
||||
const document = activeEditor.document;
|
||||
const position = activeEditor.selection.active;
|
||||
|
||||
try {
|
||||
const edit = convertMarkdownToWikilinkAtPosition(
|
||||
document.getText(),
|
||||
fromVsCodeUri(document.uri),
|
||||
{
|
||||
line: position.line,
|
||||
character: position.character,
|
||||
},
|
||||
foam.workspace,
|
||||
foam.services.parser
|
||||
);
|
||||
|
||||
if (!edit) {
|
||||
vscode.window.showInformationMessage(
|
||||
'No markdown link found at cursor position'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Apply the edit to the document
|
||||
const range = toVsCodeRange(edit.range);
|
||||
const success = await activeEditor.edit(editBuilder => {
|
||||
editBuilder.replace(range, edit.newText);
|
||||
});
|
||||
|
||||
// Position cursor at the end of the updated text
|
||||
if (success) {
|
||||
const newEndPosition = new vscode.Position(
|
||||
range.start.line,
|
||||
range.start.character + edit.newText.length
|
||||
);
|
||||
activeEditor.selection = new vscode.Selection(
|
||||
newEndPosition,
|
||||
newEndPosition
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
Logger.error('Failed to convert markdown link to wikilink', error);
|
||||
vscode.window.showErrorMessage(
|
||||
`Failed to convert markdown link to wikilink: ${error.message}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
/* @unit-ready */
|
||||
import { env, Position, Selection, commands } from 'vscode';
|
||||
import { env, Selection, commands } from 'vscode';
|
||||
import { createFile, showInEditor } from '../../test/test-utils-vscode';
|
||||
import { removeBrackets, toTitleCase } from './copy-without-brackets';
|
||||
|
||||
@@ -10,7 +10,7 @@ describe('copy-without-brackets command', () => {
|
||||
'file.md',
|
||||
]);
|
||||
const { editor } = await showInEditor(uri);
|
||||
editor.selection = new Selection(new Position(0, 0), new Position(1, 0));
|
||||
editor.selection = new Selection(0, 0, 1, 0);
|
||||
await commands.executeCommand('foam-vscode.copy-without-brackets');
|
||||
const value = await env.clipboard.readText();
|
||||
expect(value).toEqual('This is my Test Content.');
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
/* @unit-ready */
|
||||
import { commands, window, workspace } from 'vscode';
|
||||
import { toVsCodeUri } from '../../utils/vsc-utils';
|
||||
import { createFile } from '../../test/test-utils-vscode';
|
||||
import { cleanWorkspace, createFile } from '../../test/test-utils-vscode';
|
||||
|
||||
describe('create-note-from-template command', () => {
|
||||
beforeAll(async () => {
|
||||
await cleanWorkspace();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user