Compare commits

...

25 Commits

Author SHA1 Message Date
Riccardo Ferretti
f4eaf5c5ff v0.22.1 2023-04-15 13:39:00 -07:00
Riccardo Ferretti
b4830eaf30 Prepare for next release 2023-04-15 13:38:28 -07:00
Jim Graham
0cda6aed50 Allow for # alone to trigger tag completion (#1192)
* Allow for `#` alone to trigger tag completion

In #1183, I reused [HASHTAG_REGEX](83a90177b9/packages/foam-vscode/src/core/utils/hashtags.ts (L2-L3))
to validate the tag line when the `CompletionProvider` was triggered.

I wanted to prevent this:

```markdown
 # This is a Markdown header
```

but using the `HASHTAG_REGEX` had the side effect of requiring an
_additional_ character to trigger the completion provider.

```markdown

1. #p <-- triggers completion
2. #  <-- does not trigger
3. #_ (space) <-- does not trigger
```
both 1. and 2. should have triggered.

To fix, I use a slightly different regex that uses a negative lookahead
to ensure that the `#` is not followed by a space. I also added spec
cases to cover this situation.

* Update regex for more robust detection of tags

Update the regex used for more robust detection of tags. Replace the
negative lookahead assertion `\s` with `[ \t]` (allow for `\n`), and
add `#` to the class so that `##` is ignored.

Attempted to add the negation `^[0-9p{L}p{Emoji}p{N}-/]` to the
negative look ahead. This was to exclude items like `#$`, `#&` that
can't be tags. However my regex-fu was insufficient.

Instead, if the regex match is to a single `#`, ensure it is the
character to the left of the cursor. Example

  `this is text #%|`

where the `|` represents the cursor. The `TAG_REGEX`
will match the `#` at index 13. However since the cursor is at 15, the
Completion provider will not run.

Update the tests to cover these situations and add them all to a sub-
`describe` block labeled by the bug issue number #1189

* Use regex groups to determine match position

For the case like `here is #my-tag and now # |`, where `|` is the cursor
position after a trailing space, the match on `#my-tag` would allow tag
completion at the cursor position.

Ensure that the last regexp match group covers up to the the cursor
position. This also handles the case of `#$` because the match will only
be `#`.
2023-04-15 22:34:55 +02:00
Riccardo Ferretti
89c9bb5a7f v0.22.0 2023-04-15 10:47:20 -07:00
Riccardo Ferretti
941e870a65 Prepare for 0.22.0 2023-04-15 10:47:06 -07:00
Riccardo
c6655c33ff Fixed #1193 and added tests (#1197) 2023-04-15 19:31:48 +02:00
Riccardo
c94fb18f8a Resource tree items improvements (#1196)
* Consolidated common tree view code and migrated placeholder panel
* Migrated backlink panel to new pattern
* Tweaked code and fixed tests
2023-04-15 19:21:24 +02:00
Riccardo
cbd55bac74 Fix #1134 - added support for deep tag hierarchy (#1194) 2023-04-15 02:22:12 +02:00
Riccardo Ferretti
83a90177b9 v0.21.4 2023-04-14 10:39:05 -07:00
Riccardo Ferretti
37aec28af6 Prepare for next release 2023-04-14 10:38:46 -07:00
Riccardo Ferretti
447f7fc068 Fix for #1188 and #1190 - escape backslash in YAML property of generated daily note template 2023-04-14 10:37:37 -07:00
Riccardo Ferretti
ad1243665a Removed unnecessary log message 2023-04-13 17:23:38 -07:00
Riccardo Ferretti
f07de73bc4 v0.21.3 2023-04-12 17:05:49 -07:00
Riccardo Ferretti
c431ccfb62 Preparation for next release 2023-04-12 17:05:25 -07:00
Riccardo Ferretti
f31ef897cc Fix #1188 - Fixed path relative to workspace root 2023-04-12 17:04:23 -07:00
Riccardo Ferretti
7a5f45c0ce v0.21.2 2023-04-11 13:51:30 -06:00
Riccardo Ferretti
df32d9e708 Preparation for next release 2023-04-11 13:51:13 -06:00
Riccardo Ferretti
b3d4691bfa Fix #1170 newNotePath not always working
This commit fixes an issue in the note creation flow where a template is used. Before the fix, when a template is present, Foam would force an absolute path resolution instead of delegating that step to the note creation. After the fix this step is removed and the resolution is left to the note creation code
2023-04-11 13:45:14 -06:00
allcontributors[bot]
f5260f7d3f add jimgraham as a contributor for code (#1185)
* update docs/index.md [skip ci]

* update readme.md [skip ci]

* update .all-contributorsrc [skip ci]

---------

Co-authored-by: allcontributors[bot] <46447321+allcontributors[bot]@users.noreply.github.com>
2023-04-09 03:36:21 +02:00
Jim Graham
9b4b7ec84d Use the HASHTAG_REGEX to identify tags (#1183)
Instead of a generic `TAG_REGEX`, use the more specific `HASHTAG_REGEX` to identify a tag when launching tab completion.

Also add a new test case with the issue number in the test case description
2023-04-09 03:35:48 +02:00
Riccardo Ferretti
52b7f86a9f Updated TOC in FAQ docs 2023-03-22 10:56:11 -06:00
Joe Taber
2db7060124 Add GH Action workflow to sync user docs to template repo on change (#1180)
Also strips wikilinks during docs sync
fixes #1177
2023-03-22 17:47:22 +01:00
Riccardo
a4f04b3b6b Fixed temporary template generated (#1175) 2023-02-26 23:44:09 +01:00
Riccardo
b5a8a5d7c7 Chore - improved Text Edit API (#1174) 2023-02-26 23:37:50 +01:00
Riccardo
f5a29e431c Fix 1168 - relative path in embed (#1173)
* added isEmbed flag to ResourceLink
* Added tests for new embed support
2023-02-26 23:02:16 +01:00
44 changed files with 865 additions and 372 deletions

View File

@@ -1013,6 +1013,15 @@
"contributions": [
"code"
]
},
{
"login": "jimgraham",
"name": "Jim Graham",
"avatar_url": "https://avatars.githubusercontent.com/u/430293?v=4",
"profile": "http://www.jim-graham.net/",
"contributions": [
"code"
]
}
],
"contributorsPerLine": 7,

43
.github/workflows/update-docs.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
name: Update Docs
on:
push:
branches:
- master
paths:
- docs/user/**/*
workflow_dispatch:
jobs:
update-docs:
runs-on: ubuntu-22.04
timeout-minutes: 10
steps:
- uses: actions/checkout@v3
with:
repository: foambubble/foam-template
path: foam-template
- uses: actions/checkout@v3
with:
path: foam
- name: Copy and fixup user docs files
id: copy
run: |
rm -r foam-template/docs
cp -r foam/docs/user foam-template/docs
# 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))
# Set the commit message format
echo "message=Docs sync @ $(cd foam; git log --pretty='format:%h %s')" >> $GITHUB_OUTPUT
- uses: peter-evans/create-pull-request@v4
with:
token: ${{ secrets.FOAM_DOCS_SYNC_TOKEN }}
path: foam-template
commit-message: ${{ steps.copy.outputs.message }}
branch: bot/foam-docs-sync
delete-branch: true
title: Sync docs from foam
body: Copy docs from main foam repo

View File

@@ -248,6 +248,7 @@ If that sounds like something you're interested in, I'd love to have you along o
<td align="center" valign="top" width="14.28%"><a href="https://github.com/badsketch"><img src="https://avatars.githubusercontent.com/u/8953212?v=4?s=60" width="60px;" alt="Daniel Wang"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=badsketch" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://yongliangliu.com"><img src="https://avatars.githubusercontent.com/u/41845017?v=4?s=60" width="60px;" alt="Liu YongLiang"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=tlylt" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://scottakerman.com"><img src="https://avatars.githubusercontent.com/u/15224439?v=4?s=60" width="60px;" alt="Scott Akerman"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Skakerman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jim-graham.net/"><img src="https://avatars.githubusercontent.com/u/430293?v=4?s=60" width="60px;" alt="Jim Graham"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimgraham" title="Code">💻</a></td>
</tr>
</tbody>
</table>

View File

@@ -5,6 +5,7 @@
- [Frequently Asked Questions](#frequently-asked-questions)
- [Links/Graphs/BackLinks don't work. How do I enable them?](#linksgraphsbacklinks-dont-work-how-do-i-enable-them)
- [I don't want Foam enabled for all my workspaces](#i-dont-want-foam-enabled-for-all-my-workspaces)
- [I want to publish the graph view to GitHub pages or Vercel](#i-want-to-publish-the-graph-view-to-github-pages-or-vercel)
## Links/Graphs/BackLinks don't work. How do I enable them?

View File

@@ -1,8 +1,10 @@
# 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 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.
> See also [[frequently-asked-questions]].

View File

@@ -4,5 +4,5 @@
],
"npmClient": "yarn",
"useWorkspaces": true,
"version": "0.21.1"
"version": "0.22.1"
}

View File

@@ -4,6 +4,45 @@ 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.22.1] - 2023-04-15
Fixes and Improvements:
- Allow the `#` char to trigger tag autocompletion (#1192, #1189 - thanks @jimgraham)
## [0.22.0] - 2023-04-15
Fixes and Improvements:
- Added support for deep tag hierarchy in Tag Explorer panel (#1134, #1194)
- Consolidated and improved Backlinks, Placeholders and Orphans panels (#1196)
- Fixed note resolution when using template without defined path (#1197)
## [0.21.4] - 2023-04-14
Fixes and Improvements:
- Fixed issue with generated daily note template due to path escape (#1188, #1190)
## [0.21.3] - 2023-04-12
Fixes and Improvements:
- Fixed relative path from workspace root in templates (#1188)
## [0.21.2] - 2023-04-11
Fixes and Improvements:
- Fixed embed with relative paths (#1168, #1170)
- Improved multi-root folder support for daily notes (#1126, #1175)
- Improved use of tag completion (#1183 - thanks @jimgraham)
- Fixed relative path use in note creation when using templates (#1170)
Internal:
- Sync user docs with foam-template docs (#1180 - thanks @infogulch)
## [0.21.1] - 2023-02-24
Fixes and Improvements:

View File

@@ -0,0 +1,33 @@
The MIT Licence (MIT)
Copyright 2020 - present Jani Eväkallio <jani.evakallio@gmail.com>
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
Where noted, some code uses the following license:
MIT License
Copyright (c) 2015 - present Microsoft Corporation
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

View File

@@ -8,7 +8,7 @@
"type": "git"
},
"homepage": "https://github.com/foambubble/foam",
"version": "0.21.1",
"version": "0.22.1",
"license": "MIT",
"publisher": "foam",
"engines": {

View File

@@ -1,43 +0,0 @@
import os from 'os';
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { Range } from '../model/range';
export interface TextEdit {
range: Range;
newText: string;
}
/**
*
* @param text text on which the textEdit will be applied
* @param textEdit
* @returns {string} text with the applied textEdit
*/
export const applyTextEdit = (text: string, textEdit: TextEdit): string => {
const eol = detectNewline(text) || os.EOL;
const lines = text.split(eol);
const characters = text.split('');
const startOffset = getOffset(lines, textEdit.range.start, eol);
const endOffset = getOffset(lines, textEdit.range.end, eol);
const deleteCount = endOffset - startOffset;
const textToAppend = `${textEdit.newText}`;
characters.splice(startOffset, deleteCount, textToAppend);
return characters.join('');
};
const getOffset = (
lines: string[],
position: Position,
eol: string
): number => {
const eolLen = eol.length;
let offset = 0;
let i = 0;
while (i < position.line && i < lines.length) {
offset = offset + lines[i].length + eolLen;
i++;
}
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};

View File

@@ -1,7 +1,7 @@
import matter from 'gray-matter';
import { TextEdit } from './apply-text-edit';
import { Resource } from '../model/note';
import { Range } from '../model/range';
import { TextEdit } from '../services/text-edit';
import { getHeadingFromFileName } from '../utils';
export const generateHeading = async (

View File

@@ -5,8 +5,8 @@ import {
stringifyMarkdownLinkReferenceDefinition,
} from '../services/markdown-provider';
import { FoamWorkspace } from '../model/workspace';
import { TextEdit } from './apply-text-edit';
import { Position } from '../model/position';
import { TextEdit } from '../services/text-edit';
export const LINK_REFERENCE_DEFINITION_HEADER = `[//begin]: # "Autogenerated link references for markdown compatibility"`;
export const LINK_REFERENCE_DEFINITION_FOOTER = `[//end]: # "Autogenerated link references"`;

View File

@@ -5,6 +5,7 @@ export interface ResourceLink {
type: 'wikilink' | 'link';
rawText: string;
range: Range;
isEmbed: boolean;
}
export interface NoteLinkDefinition {

View File

@@ -69,4 +69,8 @@ export abstract class Range {
static isBefore(a: Range, b: Range): number {
return a.start.line - b.start.line || a.start.character - b.start.character;
}
static toString(range: Range): string {
return `${range.start.line}:${range.start.character} - ${range.end.line}:${range.end.character}`;
}
}

View File

@@ -107,6 +107,7 @@ describe('MarkdownLink', () => {
type: 'link',
rawText: '[link](#section)',
range: Range.create(0, 0),
isEmbed: false,
};
const parsed = MarkdownLink.analyzeLink(link);
expect(parsed.target).toEqual('');
@@ -161,7 +162,7 @@ describe('MarkdownLink', () => {
target: 'new-link',
});
expect(edit.newText).toEqual(`[[new-link#section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
@@ -172,7 +173,7 @@ describe('MarkdownLink', () => {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[wikilink#new-section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
@@ -184,7 +185,7 @@ describe('MarkdownLink', () => {
section: 'new-section',
});
expect(edit.newText).toEqual(`[[new-link#new-section]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
@@ -195,7 +196,7 @@ describe('MarkdownLink', () => {
section: '',
});
expect(edit.newText).toEqual(`[[wikilink]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to rename the alias', () => {
const link = parser.parse(getRandomURI(), `this is a [[wikilink|alias]]`)
@@ -204,7 +205,7 @@ describe('MarkdownLink', () => {
alias: 'new-alias',
});
expect(edit.newText).toEqual(`[[wikilink|new-alias]]`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
});
@@ -216,7 +217,7 @@ describe('MarkdownLink', () => {
target: 'to/another-path.md',
});
expect(edit.newText).toEqual(`[link](to/another-path.md)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename the section only', () => {
const link = parser.parse(
@@ -227,7 +228,7 @@ describe('MarkdownLink', () => {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/path.md#section2)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should rename both target and section', () => {
const link = parser.parse(
@@ -239,7 +240,7 @@ describe('MarkdownLink', () => {
section: 'section2',
});
expect(edit.newText).toEqual(`[link](to/another-path.md#section2)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
it('should be able to remove the section', () => {
const link = parser.parse(
@@ -250,7 +251,7 @@ describe('MarkdownLink', () => {
section: '',
});
expect(edit.newText).toEqual(`[link](to/path.md)`);
expect(edit.selection).toEqual(link.range);
expect(edit.range).toEqual(link.range);
});
});
});

View File

@@ -46,16 +46,17 @@ export abstract class MarkdownLink {
const newAlias = delta.alias ?? alias ?? '';
const sectionDivider = newSection ? '#' : '';
const aliasDivider = newAlias ? '|' : '';
const embed = link.isEmbed ? '!' : '';
if (link.type === 'wikilink') {
return {
newText: `[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
selection: link.range,
newText: `${embed}[[${newTarget}${sectionDivider}${newSection}${aliasDivider}${newAlias}]]`,
range: link.range,
};
}
if (link.type === 'link') {
return {
newText: `[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
selection: link.range,
newText: `${embed}[${newAlias}](${newTarget}${sectionDivider}${newSection})`,
range: link.range,
};
}
throw new Error(

View File

@@ -39,6 +39,7 @@ describe('Markdown parsing', () => {
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.rawText).toEqual('[link to page b](../doc/page-b.md)');
expect(link.isEmbed).toBeFalsy();
});
it('should detect links that have formatting in label', () => {
@@ -48,6 +49,15 @@ describe('Markdown parsing', () => {
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.isEmbed).toBeFalsy();
});
it('should detect embed links', () => {
const note = createNoteFromMarkdown('this is ![link](../doc/page-b.md)');
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('link');
expect(link.isEmbed).toBeTruthy();
});
it('should detect wikilinks', () => {
@@ -61,6 +71,16 @@ describe('Markdown parsing', () => {
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[a file]]');
expect(link.isEmbed).toBeFalsy();
});
it('should detect wikilink embeds', () => {
const note = createNoteFromMarkdown('Some content and ![[an embed]]');
expect(note.links.length).toEqual(1);
const link = note.links[0];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('![[an embed]]');
expect(link.isEmbed).toBeTruthy();
});
it('should detect wikilinks that have aliases', () => {
@@ -74,6 +94,7 @@ describe('Markdown parsing', () => {
link = note.links[1];
expect(link.type).toEqual('wikilink');
expect(link.rawText).toEqual('[[other link | spaced]]');
expect(link.isEmbed).toBeFalsy();
});
it('should skip wikilinks in codeblocks', () => {

View File

@@ -299,18 +299,33 @@ const wikilinkPlugin: ParserPlugin = {
name: 'wikilink',
visit: (node, note, noteSource) => {
if (node.type === 'wikiLink') {
const isEmbed =
noteSource.charAt(node.position!.start.offset - 1) === '!';
const literalContent = noteSource.substring(
node.position!.start.offset!,
isEmbed
? node.position!.start.offset! - 1
: node.position!.start.offset!,
node.position!.end.offset!
);
const range = isEmbed
? Range.create(
node.position.start.line - 1,
node.position.start.column - 2,
node.position.end.line - 1,
node.position.end.column - 1
)
: astPositionToFoamRange(node.position!);
note.links.push({
type: 'wikilink',
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
range,
isEmbed,
});
}
if (node.type === 'link') {
if (node.type === 'link' || node.type === 'image') {
const targetUri = (node as any).url;
const uri = note.uri.resolve(targetUri);
if (uri.scheme !== 'file' || uri.path === note.uri.path) {
@@ -324,6 +339,7 @@ const wikilinkPlugin: ParserPlugin = {
type: 'link',
rawText: literalContent,
range: astPositionToFoamRange(node.position!),
isEmbed: literalContent.startsWith('!'),
});
}
},

View File

@@ -1,6 +1,6 @@
import { Range } from '../model/range';
import { Logger } from '../utils/log';
import { applyTextEdit } from './apply-text-edit';
import { TextEdit } from './text-edit';
Logger.setLevel('error');
@@ -23,7 +23,7 @@ describe('applyTextEdit', () => {
3. this is third line
4. this is fourth line`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});
@@ -45,7 +45,7 @@ describe('applyTextEdit', () => {
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});
@@ -68,7 +68,7 @@ describe('applyTextEdit', () => {
3. this is third line
`;
const actual = applyTextEdit(text, textEdit);
const actual = TextEdit.apply(text, textEdit);
expect(actual).toBe(expected);
});

View File

@@ -0,0 +1,44 @@
import detectNewline from 'detect-newline';
import { Position } from '../model/position';
import { Range } from '../model/range';
export interface TextEdit {
range: Range;
newText: string;
}
export abstract class TextEdit {
/**
*
* @param text text on which the textEdit will be applied
* @param textEdit
* @returns {string} text with the applied textEdit
*/
public static apply(text: string, textEdit: TextEdit): string {
const eol = detectNewline.graceful(text);
const lines = text.split(eol);
const characters = text.split('');
const startOffset = getOffset(lines, textEdit.range.start, eol);
const endOffset = getOffset(lines, textEdit.range.end, eol);
const deleteCount = endOffset - startOffset;
const textToAppend = `${textEdit.newText}`;
characters.splice(startOffset, deleteCount, textToAppend);
return characters.join('');
}
}
const getOffset = (
lines: string[],
position: Position,
eol: string
): number => {
const eolLen = eol.length;
let offset = 0;
let i = 0;
while (i < position.line && i < lines.length) {
offset = offset + lines[i].length + eolLen;
i++;
}
return offset + Math.min(position.character, lines[i]?.length ?? 0);
};

View File

@@ -1,5 +1,5 @@
import { isSome } from './core';
const HASHTAG_REGEX =
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;

View File

@@ -86,9 +86,7 @@ export async function createDailyNoteIfNotExists(targetDate: Date) {
const templateFallbackText = `---
foam_template:
filepath: "${workspace.asRelativePath(
toVsCodeUri(pathFromLegacyConfiguration)
)}"
filepath: "${pathFromLegacyConfiguration.toFsPath().replace(/\\/g, '\\\\')}"
---
# ${dateFormat(targetDate, titleFormat, false)}
`;

View File

@@ -20,8 +20,8 @@ import { Foam } from '../../core/model/foam';
import { Resource } from '../../core/model/note';
import { generateHeading, generateLinkReferences } from '../../core/janitor';
import { Range } from '../../core/model/range';
import { applyTextEdit } from '../../core/janitor/apply-text-edit';
import detectNewline from 'detect-newline';
import { TextEdit } from '../../core/services/text-edit';
const feature: FoamFeature = {
activate: (context: ExtensionContext, foamPromise: Promise<Foam>) => {
@@ -130,8 +130,8 @@ async function runJanitor(foam: Foam) {
// Note: The ordering matters. Definitions need to be inserted
// before heading, since inserting a heading changes line numbers below
let text = noteText;
text = definitions ? applyTextEdit(text, definitions) : text;
text = heading ? applyTextEdit(text, heading) : text;
text = definitions ? TextEdit.apply(text, definitions) : text;
text = heading ? TextEdit.apply(text, heading) : text;
return workspace.fs.writeFile(toVsCodeUri(note.uri), Buffer.from(text));
});

View File

@@ -79,14 +79,6 @@ async function openResource(workspace: FoamWorkspace, args?: OpenResourceArgs) {
: toVsCodeUri(item.uri.asPlain());
return vscode.commands.executeCommand('vscode.open', targetUri);
}
Logger.info(
`${OPEN_COMMAND.command}: No resource matches given args`,
JSON.stringify(args)
);
vscode.window.showInformationMessage(
`${OPEN_COMMAND.command}: No resource matches given args`
);
}
const feature: FoamFeature = {

View File

@@ -6,12 +6,15 @@ import {
createNote,
getUriInWorkspace,
} from '../../test/test-utils-vscode';
import { BacklinksTreeDataProvider, BacklinkTreeItem } from './backlinks';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { BacklinksTreeDataProvider } from './backlinks';
import { OPEN_COMMAND } from '../commands/open-resource';
import { toVsCodeUri } from '../../utils/vsc-utils';
import { FoamGraph } from '../../core/model/graph';
import { URI } from '../../core/model/uri';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
} from '../../utils/tree-view-utils';
describe('Backlinks panel', () => {
beforeAll(async () => {
@@ -84,19 +87,19 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
expect(linksFromB.map(l => l.link)).toEqual(
noteB.links.sort(
(a, b) => a.range.start.character - b.range.start.character
)
)) as ResourceRangeTreeItem[];
expect(linksFromB.map(l => l.range)).toEqual(
noteB.links
.map(l => l.range)
.sort((a, b) => a.start.character - b.start.character)
);
});
it('navigates to the document if clicking on note', async () => {
provider.target = noteA.uri;
const notes = (await provider.getChildren()) as ResourceTreeItem[];
expect(notes[0].command).toMatchObject({
command: OPEN_COMMAND.command,
arguments: [expect.objectContaining({ uri: noteB.uri })],
command: 'vscode.open',
arguments: [expect.objectContaining({ path: noteB.uri.path })],
});
});
it('navigates to document with link selection if clicking on backlink', async () => {
@@ -104,11 +107,11 @@ describe('Backlinks panel', () => {
const notes = (await provider.getChildren()) as ResourceTreeItem[];
const linksFromB = (await provider.getChildren(
notes[0]
)) as BacklinkTreeItem[];
)) as ResourceRangeTreeItem[];
expect(linksFromB[0].command).toMatchObject({
command: 'vscode.open',
arguments: [
noteB.uri,
expect.objectContaining({ path: noteB.uri.path }),
{
selection: expect.arrayContaining([]),
},

View File

@@ -1,16 +1,17 @@
import * as vscode from 'vscode';
import { groupBy } from 'lodash';
import { URI } from '../../core/model/uri';
import { getNoteTooltip, isNone } from '../../utils';
import { isNone } from '../../utils';
import { FoamFeature } from '../../types';
import { ResourceTreeItem } from '../../utils/grouped-resources-tree-data-provider';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { FoamGraph } from '../../core/model/graph';
import { Resource, ResourceLink } from '../../core/model/note';
import { Range } from '../../core/model/range';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { fromVsCodeUri } from '../../utils/vsc-utils';
import {
ResourceRangeTreeItem,
ResourceTreeItem,
groupRangesByResource,
} from '../../utils/tree-view-utils';
const feature: FoamFeature = {
activate: async (
@@ -37,7 +38,7 @@ const feature: FoamFeature = {
export default feature;
export class BacklinksTreeDataProvider
implements vscode.TreeDataProvider<BacklinkPanelTreeItem>
implements vscode.TreeDataProvider<vscode.TreeItem>
{
public target?: URI = undefined;
// prettier-ignore
@@ -54,67 +55,32 @@ export class BacklinksTreeDataProvider
return item;
}
getChildren(item?: ResourceTreeItem): Promise<BacklinkPanelTreeItem[]> {
getChildren(item?: BacklinkPanelTreeItem): Promise<vscode.TreeItem[]> {
const uri = this.target;
if (item) {
const resource = item.resource;
const backlinkRefs = Promise.all(
resource.links
.filter(link =>
this.workspace.resolveLink(resource, link).asPlain().isEqual(uri)
)
.map(async link => {
const item = new BacklinkTreeItem(resource, link);
const lines = (
(await this.workspace.readAsMarkdown(resource.uri)) ?? ''
).split('\n');
if (link.range.start.line < lines.length) {
const line = lines[link.range.start.line];
const start = Math.max(0, link.range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
item.label = `${link.range.start.line}: ${ellipsis}${line.substr(
start,
300
)}`;
item.tooltip = getNoteTooltip(line);
}
return item;
})
);
return backlinkRefs;
if (item && item instanceof ResourceTreeItem) {
return item.getChildren();
}
if (isNone(uri) || isNone(this.workspace.find(uri))) {
return Promise.resolve([]);
}
const backlinksByResourcePath = groupBy(
this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri)),
b => b.source.path
);
const connections = this.graph
.getConnections(uri)
.filter(c => c.target.asPlain().isEqual(uri));
const resources = Object.keys(backlinksByResourcePath)
.map(res => backlinksByResourcePath[res][0].source)
.map(uri => this.workspace.get(uri))
.sort(Resource.sortByTitle)
.map(note => {
const connections = backlinksByResourcePath[note.uri.path].sort(
(a, b) => Range.isBefore(a.link.range, b.link.range)
);
const item = new ResourceTreeItem(
note,
this.workspace,
vscode.TreeItemCollapsibleState.Expanded
);
item.description = `(${connections.length}) ${item.description}`;
return item;
});
return Promise.resolve(resources);
const backlinkItems = connections.map(c =>
ResourceRangeTreeItem.createStandardItem(
this.workspace,
this.workspace.get(c.source),
c.link.range
)
);
return groupRangesByResource(
this.workspace,
backlinkItems,
vscode.TreeItemCollapsibleState.Expanded
);
}
resolveTreeItem(item: BacklinkPanelTreeItem): Promise<BacklinkPanelTreeItem> {
@@ -122,23 +88,4 @@ export class BacklinksTreeDataProvider
}
}
export class BacklinkTreeItem extends vscode.TreeItem {
constructor(
public readonly resource: Resource,
public readonly link: ResourceLink
) {
super(link.rawText, vscode.TreeItemCollapsibleState.None);
this.label = `${link.range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: link.range }],
title: 'Go to link',
};
}
resolveTreeItem(): Promise<BacklinkTreeItem> {
return Promise.resolve(this);
}
}
type BacklinkPanelTreeItem = ResourceTreeItem | BacklinkTreeItem;
type BacklinkPanelTreeItem = ResourceTreeItem | ResourceRangeTreeItem;

View File

@@ -3,11 +3,8 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getOrphansConfig } from '../../settings';
import { FoamFeature } from '../../types';
import {
GroupedResourcesTreeDataProvider,
ResourceTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from '../../utils/tree-view-utils';
const EXCLUDE_TYPES = ['image', 'attachment'];
const feature: FoamFeature = {

View File

@@ -3,10 +3,12 @@ import { Foam } from '../../core/model/foam';
import { createMatcherAndDataStore } from '../../services/editor';
import { getPlaceholdersConfig } from '../../settings';
import { FoamFeature } from '../../types';
import { GroupedResourcesTreeDataProvider } from '../../utils/grouped-resources-tree-data-provider';
import {
GroupedResourcesTreeDataProvider,
ResourceRangeTreeItem,
UriTreeItem,
} from '../../utils/grouped-resources-tree-data-provider';
groupRangesByResource,
} from '../../utils/tree-view-utils';
const feature: FoamFeature = {
activate: async (
@@ -22,7 +24,21 @@ const feature: FoamFeature = {
'placeholder',
() => foam.graph.getAllNodes().filter(uri => uri.isPlaceholder()),
uri => {
return new UriTreeItem(uri);
return new UriTreeItem(uri, {
icon: 'link',
getChildren: async () => {
return groupRangesByResource(
foam.workspace,
foam.graph.getBacklinks(uri).map(link => {
return ResourceRangeTreeItem.createStandardItem(
foam.workspace,
foam.workspace.get(link.source),
link.link.range
);
})
);
},
});
},
matcher
);

View File

@@ -1,65 +1,48 @@
import {
createTestNote,
readFileFromFs,
TEST_DATA_DIR,
} from '../../test/test-utils';
import { createTestNote } from '../../test/test-utils';
import { cleanWorkspace, closeEditors } from '../../test/test-utils-vscode';
import { TagItem, TagReference, TagsProvider } from './tags-explorer';
import { bootstrap, Foam } from '../../core/model/foam';
import { MarkdownResourceProvider } from '../../core/services/markdown-provider';
import { createMarkdownParser } from '../../core/services/markdown-parser';
import { URI } from '../../core/model/uri';
import { FileDataStore, Matcher } from '../../test/test-datastore';
import { FoamTags } from '../../core/model/tags';
import { FoamWorkspace } from '../../core/model/workspace';
describe('Tags tree panel', () => {
let _foam: Foam;
let provider: TagsProvider;
const dataStore = new FileDataStore(readFileFromFs, TEST_DATA_DIR.toFsPath());
const matcher = new Matcher([URI.file(TEST_DATA_DIR.toFsPath())]);
const parser = createMarkdownParser();
const mdProvider = new MarkdownResourceProvider(dataStore, parser);
beforeAll(async () => {
await cleanWorkspace();
});
afterAll(async () => {
_foam.dispose();
await cleanWorkspace();
});
beforeEach(async () => {
_foam = await bootstrap(matcher, undefined, dataStore, parser, [
mdProvider,
]);
provider = new TagsProvider(_foam, _foam.workspace);
await closeEditors();
});
afterEach(() => {
_foam.dispose();
});
it('correctly provides a tag from a set of notes', async () => {
it('provides a tag from a set of notes', async () => {
const noteA = createTestNote({
tags: ['test'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const treeItems = (await provider.getChildren()) as TagItem[];
treeItems.forEach(item => expect(item.tag).toContain('test'));
expect(treeItems).toHaveLength(1);
expect(treeItems[0].label).toEqual('test');
expect(treeItems[0].tag).toEqual('test');
expect(treeItems[0].nResourcesInSubtree).toEqual(1);
});
it('correctly handles a parent and child tag', async () => {
it('handles a simple parent and child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -78,17 +61,18 @@ describe('Tags tree panel', () => {
});
});
it('correctly handles a single parent and multiple child tag', async () => {
it('handles a single parent and multiple child tag', async () => {
const noteA = createTestNote({
tags: ['parent/child'],
uri: './note-a.md',
});
_foam.workspace.set(noteA);
const noteB = createTestNote({
tags: ['parent/subchild'],
uri: './note-b.md',
});
_foam.workspace.set(noteB);
const workspace = new FoamWorkspace().set(noteA).set(noteB);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
@@ -114,14 +98,15 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('correctly handles a single parent and child tag in the same note', async () => {
it('handles a parent and child tag in the same note', async () => {
const noteC = createTestNote({
tags: ['main', 'main/subtopic'],
title: 'Test note',
uri: './note-c.md',
});
_foam.workspace.set(noteC);
const workspace = new FoamWorkspace().set(noteC);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
@@ -151,4 +136,36 @@ describe('Tags tree panel', () => {
expect(childTreeItems).toHaveLength(3);
});
it('handles a tag with multiple levels of hierarchy - #1134', async () => {
const noteA = createTestNote({
tags: ['parent/child/second'],
uri: './note-a.md',
});
const workspace = new FoamWorkspace().set(noteA);
const foamTags = FoamTags.fromWorkspace(workspace);
const provider = new TagsProvider(foamTags, workspace);
provider.refresh();
const parentTreeItems = (await provider.getChildren()) as TagItem[];
const parentTagItem = parentTreeItems.pop();
expect(parentTagItem.title).toEqual('parent');
const childTreeItems = (await provider.getChildren(
parentTagItem
)) as TagItem[];
expect(childTreeItems).toHaveLength(2);
expect(childTreeItems[0].label).toMatch(/^Search.*/);
expect(childTreeItems[1].label).toEqual('child');
const grandchildTreeItems = (await provider.getChildren(
childTreeItems[1]
)) as TagItem[];
expect(grandchildTreeItems).toHaveLength(2);
expect(grandchildTreeItems[0].label).toMatch(/^Search.*/);
expect(grandchildTreeItems[1].label).toEqual('second');
});
});

View File

@@ -6,6 +6,7 @@ import { toVsCodeRange, toVsCodeUri } from '../../utils/vsc-utils';
import { Foam } from '../../core/model/foam';
import { FoamWorkspace } from '../../core/model/workspace';
import { Resource, Tag } from '../../core/model/note';
import { FoamTags } from '../../core/model/tags';
const TAG_SEPARATOR = '/';
const feature: FoamFeature = {
@@ -14,7 +15,7 @@ const feature: FoamFeature = {
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
const provider = new TagsProvider(foam, foam.workspace);
const provider = new TagsProvider(foam.tags, foam.workspace);
const treeView = vscode.window.createTreeView('foam-vscode.tags-explorer', {
treeDataProvider: provider,
showCollapseAll: true,
@@ -48,7 +49,10 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
notes: URI[];
}[];
constructor(private foam: Foam, private workspace: FoamWorkspace) {
private foamTags: FoamTags;
constructor(tags: FoamTags, private workspace: FoamWorkspace) {
this.foamTags = tags;
this.computeTags();
}
@@ -58,7 +62,7 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
}
private computeTags() {
this.tags = [...this.foam.tags.tags]
this.tags = [...this.foamTags.tags]
.map(([tag, notes]) => ({ tag, notes }))
.sort((a, b) => a.tag.localeCompare(b.tag));
}
@@ -68,53 +72,55 @@ export class TagsProvider implements vscode.TreeDataProvider<TagTreeItem> {
}
getChildren(element?: TagItem): Promise<TagTreeItem[]> {
if (element) {
const nestedTagItems: TagTreeItem[] = this.tags
.filter(item => item.tag.indexOf(element.title + TAG_SEPARATOR) > -1)
.map(
item =>
new TagItem(
item.tag,
item.tag.substring(item.tag.indexOf(TAG_SEPARATOR) + 1),
item.notes
)
)
.sort((a, b) => a.title.localeCompare(b.title));
const parentTag = element ? element.tag : '';
const parentPrefix = element ? parentTag + TAG_SEPARATOR : '';
const references: TagTreeItem[] = element.notes
.map(uri => this.foam.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
return [
...acc,
...tags.slice(0, 1).map(t => new TagReference(t, note)),
];
}, [])
.sort((a, b) => a.title.localeCompare(b.title));
const tagsAtThisLevel = this.tags
.filter(({ tag }) => tag.startsWith(parentPrefix))
.map(({ tag }) => {
const nextSeparator = tag.indexOf(TAG_SEPARATOR, parentPrefix.length);
const label =
nextSeparator > -1
? tag.substring(parentPrefix.length, nextSeparator)
: tag.substring(parentPrefix.length);
const tagId = parentPrefix + label;
return { label, tagId, tag };
})
.reduce((acc, { label, tagId, tag }) => {
const existing = acc.has(label);
const nResources = this.foamTags.tags.get(tag).length ?? 0;
if (!existing) {
acc.set(label, { label, tagId, nResources: 0 });
}
acc.get(label).nResources += nResources;
return acc;
}, new Map() as Map<string, { label: string; tagId: string; nResources: number }>);
return Promise.resolve([
new TagSearch(element.tag),
...nestedTagItems,
...references,
]);
}
if (!element) {
const tags: TagItem[] = this.tags
.map(({ tag, notes }) => {
const parentTag =
tag.indexOf(TAG_SEPARATOR) > 0
? tag.substring(0, tag.indexOf(TAG_SEPARATOR))
: tag;
const tagChildren = Array.from(tagsAtThisLevel.values())
.map(({ label, tagId, nResources }) => {
const resources = this.foamTags.tags.get(tagId) ?? [];
return new TagItem(tagId, label, nResources, resources);
})
.sort((a, b) => a.title.localeCompare(b.title));
return new TagItem(parentTag, parentTag, notes);
})
.filter(
(value, index, array) =>
array.findIndex(tag => tag.title === value.title) === index
);
const referenceChildren: TagTreeItem[] = (element?.notes ?? [])
.map(uri => this.workspace.get(uri))
.reduce((acc, note) => {
const tags = note.tags.filter(t => t.label === element.tag);
return [
...acc,
...tags.slice(0, 1).map(t => new TagReference(t, note)),
];
}, [])
.sort((a, b) => a.title.localeCompare(b.title));
return Promise.resolve(tags.sort((a, b) => a.tag.localeCompare(b.tag)));
}
return Promise.resolve(
[
element && new TagSearch(element.tag),
...tagChildren,
...referenceChildren,
].filter(Boolean)
);
}
async resolveTreeItem(item: TagTreeItem): Promise<TagTreeItem> {
@@ -134,11 +140,12 @@ export class TagItem extends vscode.TreeItem {
constructor(
public readonly tag: string,
public readonly title: string,
public readonly nResourcesInSubtree: number,
public readonly notes: URI[]
) {
super(title, vscode.TreeItemCollapsibleState.Collapsed);
this.description = `${this.notes.length} reference${
this.notes.length !== 1 ? 's' : ''
this.description = `${nResourcesInSubtree} reference${
nResourcesInSubtree !== 1 ? 's' : ''
}`;
}

View File

@@ -22,7 +22,11 @@ const feature: FoamFeature = {
markdownItFoamTags,
markdownItWikilinkNavigation,
markdownItRemoveLinkReferences,
].reduce((acc, extension) => extension(acc, foam.workspace), md);
].reduce(
(acc, extension) =>
extension(acc, foam.workspace, foam.services.parser),
md
);
},
};
},

View File

@@ -24,7 +24,7 @@ describe('Displaying included notes in preview', () => {
CONFIG_EMBED_NOTE_IN_CONTAINER,
false,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
expect(
md.render(`This is the root node.
@@ -51,7 +51,7 @@ describe('Displaying included notes in preview', () => {
CONFIG_EMBED_NOTE_IN_CONTAINER,
true,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(`This is the root node. ![[note-a]]`);
expect(res).toContain('This is the root node');
@@ -80,7 +80,7 @@ This is the third section of note E
);
const parser = createMarkdownParser([]);
const ws = new FoamWorkspace().set(parser.parse(note.uri, note.content));
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
await withModifiedFoamConfiguration(
CONFIG_EMBED_NOTE_IN_CONTAINER,
@@ -123,7 +123,7 @@ This is the third section of note E
CONFIG_EMBED_NOTE_IN_CONTAINER,
true,
() => {
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(
`This is the root node. ![[note-e-container#Section 3]]`
@@ -139,7 +139,11 @@ This is the third section of note E
});
it('should fallback to the bare text when the note is not found', () => {
const md = markdownItWikilinkEmbed(MarkdownIt(), new FoamWorkspace());
const md = markdownItWikilinkEmbed(
MarkdownIt(),
new FoamWorkspace(),
parser
);
expect(md.render(`This is the root node. ![[non-existing-note]]`)).toMatch(
`<p>This is the root node. ![[non-existing-note]]</p>`
@@ -158,12 +162,12 @@ This is the third section of note E
const ws = new FoamWorkspace()
.set(parser.parse(noteA.uri, noteA.content))
.set(parser.parse(noteB.uri, noteB.content));
const md = markdownItWikilinkEmbed(MarkdownIt(), ws);
const md = markdownItWikilinkEmbed(MarkdownIt(), ws, parser);
const res = md.render(noteBText);
expect(res).toContain('This is the text of note B which includes');
expect(res).toContain('This is the text of note A which includes');
expect(res).toContain('Cyclic link detected for wikilink: note-a');
expect(res).toContain('Cyclic link detected for wikilink');
deleteFile(noteA);
deleteFile(noteB);

View File

@@ -1,20 +1,26 @@
/*global markdownit:readonly*/
// eslint-disable-next-line no-restricted-imports
import { readFileSync } from 'fs';
import { workspace as vsWorkspace } from 'vscode';
import markdownItRegex from 'markdown-it-regex';
import { isSome } from '../../utils';
import { FoamWorkspace } from '../../core/model/workspace';
import { Logger } from '../../core/utils/log';
import { Resource } from '../../core/model/note';
import { Resource, ResourceParser } from '../../core/model/note';
import { getFoamVsCodeConfig } from '../../services/config';
// eslint-disable-next-line no-restricted-imports
import { readFileSync } from 'fs';
import { fromVsCodeUri, toVsCodeUri } from '../../utils/vsc-utils';
import { MarkdownLink } from '../../core/services/markdown-link';
import { Position } from '../../core/model/position';
import { TextEdit } from '../../core/services/text-edit';
export const CONFIG_EMBED_NOTE_IN_CONTAINER = 'preview.embedNoteInContainer';
const refsStack: string[] = [];
export const markdownItWikilinkEmbed = (
md: markdownit,
workspace: FoamWorkspace
workspace: FoamWorkspace,
parser: ResourceParser
) => {
return md.use(markdownItRegex, {
name: 'embed-wikilinks',
@@ -52,6 +58,11 @@ export const markdownItWikilinkEmbed = (
.slice(section.range.start.line, section.range.end.line)
.join('\n');
}
noteText = withLinksRelativeToWorkspaceRoot(
noteText,
parser,
workspace
);
content = getFoamVsCodeConfig(CONFIG_EMBED_NOTE_IN_CONTAINER)
? `<div class="embed-container-note">${md.render(noteText)}</div>`
: noteText;
@@ -84,4 +95,32 @@ Embed for attachments is not supported
});
};
function withLinksRelativeToWorkspaceRoot(
noteText: string,
parser: ResourceParser,
workspace: FoamWorkspace
) {
const note = parser.parse(
fromVsCodeUri(vsWorkspace.workspaceFolders[0].uri),
noteText
);
const edits = note.links
.map(link => {
const info = MarkdownLink.analyzeLink(link);
const resource = workspace.find(info.target);
const pathFromRoot = vsWorkspace.asRelativePath(
toVsCodeUri(resource.uri)
);
return MarkdownLink.createUpdateLinkEdit(link, {
target: pathFromRoot,
});
})
.sort((a, b) => Position.compareTo(b.range.start, a.range.start));
const text = edits.reduce(
(text, edit) => TextEdit.apply(text, edit),
noteText
);
return text;
}
export default markdownItWikilinkEmbed;

View File

@@ -23,6 +23,7 @@ export const markdownItWikilinkNavigation = (
rawText: '[[' + wikilink + ']]',
type: 'wikilink',
range: Range.create(0, 0),
isEmbed: false,
});
const formattedSection = section ? `#${section}` : '';
const label = isEmpty(alias) ? `${target}${formattedSection}` : alias;

View File

@@ -45,7 +45,7 @@ const feature: FoamFeature = {
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
toVsCodeRange(edit.range),
edit.newText
);
break;
@@ -62,7 +62,7 @@ const feature: FoamFeature = {
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
toVsCodeRange(edit.range),
edit.newText
);
break;

View File

@@ -93,4 +93,124 @@ describe('Tag Completion', () => {
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide suggestions when inside a markdown heading #1182', async () => {
const { uri } = await createFile('# primary');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 7)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
describe('has robust triggering #1189', () => {
it('should provide multiple suggestions when typing #', async () => {
const { uri } = await createFile(`# Title
#`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # on line with match', async () => {
const { uri } = await createFile('Here is #my-tag and #');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 21)
);
expect(tags.items.length).toEqual(3);
});
it('should provide multiple suggestions when typing # at EOL', async () => {
const { uri } = await createFile(`# Title
#
more text
`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 1)
);
expect(tags.items.length).toEqual(3);
});
it('should not provide a suggestion when typing `# `', async () => {
const { uri } = await createFile(`# Title
# `);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `#{non-match}`', async () => {
const { uri } = await createFile(`# Title
#$`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `##`', async () => {
const { uri } = await createFile(`# Title
##`);
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(2, 2)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
it('should not provide a suggestion when typing `# ` in a line that already matched', async () => {
const { uri } = await createFile('here is #primary and now # ');
const { doc } = await showInEditor(uri);
const provider = new TagCompletionProvider(foamTags);
const tags = await provider.provideCompletionItems(
doc,
new vscode.Position(0, 29)
);
expect(foamTags.tags.get('primary')).toBeTruthy();
expect(tags).toBeNull();
});
});
});

View File

@@ -3,9 +3,11 @@ import { Foam } from '../core/model/foam';
import { FoamTags } from '../core/model/tags';
import { FoamFeature } from '../types';
import { mdDocSelector } from '../utils';
import { SECTION_REGEX } from './link-completion';
export const TAG_REGEX = /#(.*)/;
// this regex is different from HASHTAG_REGEX in that it does not look for a
// #+character. It uses a negative look-ahead for `# `
const TAG_REGEX =
/(?<=^|\s)#(?![ \t#])([0-9]*[\p{L}\p{Emoji_Presentation}\p{N}/_-]*)/dgu;
const feature: FoamFeature = {
activate: async (
@@ -36,13 +38,23 @@ export class TagCompletionProvider
.lineAt(position)
.text.substr(0, position.character);
const requiresAutocomplete =
cursorPrefix.match(TAG_REGEX) && !cursorPrefix.match(SECTION_REGEX);
const requiresAutocomplete = cursorPrefix.match(TAG_REGEX);
if (!requiresAutocomplete) {
return null;
}
// check the match group length.
// find the last match group, and ensure the end of that group is
// at the cursor position.
// This excludes both `#%` and also `here is #my-app1 and now # ` with
// trailing space
const matches = Array.from(cursorPrefix.matchAll(TAG_REGEX));
const lastMatch = matches[matches.length - 1];
const lastMatchEndIndex = lastMatch[0].length + lastMatch.index;
if (lastMatchEndIndex !== position.character) {
return null;
}
const completionTags = [];
[...this.foamTags.tags].forEach(([tag]) => {
const item = new vscode.CompletionItem(

View File

@@ -1,4 +1,4 @@
import { Selection, ViewColumn, window } from 'vscode';
import { Selection, Uri, ViewColumn, window, workspace } from 'vscode';
import { fromVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import {
@@ -7,6 +7,7 @@ import {
deleteFile,
getUriInWorkspace,
showInEditor,
withModifiedFoamConfiguration,
} from '../test/test-utils-vscode';
import { Resolver } from './variable-resolver';
import { fileExists } from './editor';
@@ -19,6 +20,56 @@ describe('Create note from template', () => {
});
describe('User flow', () => {
it('should resolve the path using the config when path is derived from note title', async () => {
const templateA = await createFile('Template A', [
'.foam',
'templates',
'template-a.md',
]);
jest
.spyOn(window, 'showInputBox')
.mockImplementation(jest.fn(() => Promise.resolve('Title of note')));
const noteA = await createFile('Note A', [
'path',
'of-new-note',
'note-a.md',
]);
await showInEditor(noteA.uri);
await withModifiedFoamConfiguration(
'files.newNotePath',
'currentDir',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
noteA.uri.getDirectory().joinPath('Title of note.md').path
);
await deleteFile(result.uri);
}
);
await withModifiedFoamConfiguration(
'files.newNotePath',
'root',
async () => {
const result = await NoteFactory.createFromTemplate(
templateA.uri,
new Resolver(new Map(), new Date())
);
expect(result.uri.path).toEqual(
Uri.joinPath(workspace.workspaceFolders[0].uri, 'Title of note.md')
.path
);
await deleteFile(result.uri);
}
);
await deleteFile(noteA);
await deleteFile(templateA);
});
it('should ask a user to confirm the path if note already exists', async () => {
const templateA = await createFile('Template A', [
'.foam',

View File

@@ -349,13 +349,15 @@ export const NoteFactory = {
resolver
);
const newFilePath = asAbsoluteWorkspaceUri(
template.metadata.has('filepath')
? URI.file(template.metadata.get('filepath'))
: isSome(filepathFallbackURI)
? filepathFallbackURI
: await getPathFromTitle(resolver)
);
let newFilePath = template.metadata.has('filepath')
? URI.file(template.metadata.get('filepath'))
: filepathFallbackURI;
if (isNone(newFilePath)) {
newFilePath = await getPathFromTitle(resolver);
} else if (!newFilePath.path.startsWith('./')) {
newFilePath = asAbsoluteWorkspaceUri(newFilePath);
}
return NoteFactory.createNote(
newFilePath,

View File

@@ -89,11 +89,13 @@ export const createTestNote = (params: {
type: 'wikilink',
range: range,
rawText: `[[${link.slug}]]`,
isEmbed: false,
}
: {
type: 'link',
range: range,
rawText: `[link text](${link.to})`,
isEmbed: false,
};
})
: [],

View File

@@ -9,8 +9,8 @@ import { createTestNote } from '../test/test-utils';
import {
DirectoryTreeItem,
GroupedResourcesTreeDataProvider,
UriTreeItem,
} from './grouped-resources-tree-data-provider';
import { ResourceTreeItem, UriTreeItem } from './tree-view-utils';
const testMatcher = new SubstringExcludeMatcher('path-exclude');
@@ -74,14 +74,14 @@ describe('GroupedResourcesTreeDataProvider', () => {
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Folder);
const directory = new DirectoryTreeItem(
'/path',
[new UriTreeItem(matchingNote1.uri)],
[new ResourceTreeItem(matchingNote1, workspace)],
'note'
);
const result = await provider.getChildren(directory);
@@ -90,7 +90,7 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: 'ABC',
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});
@@ -104,7 +104,7 @@ describe('GroupedResourcesTreeDataProvider', () => {
.list()
.filter(r => r.title.length === 3)
.map(r => r.uri),
uri => new UriTreeItem(uri),
uri => new ResourceTreeItem(workspace.get(uri), workspace),
testMatcher
);
provider.setGroupBy(GroupedResoucesConfigGroupBy.Off);
@@ -115,13 +115,13 @@ describe('GroupedResourcesTreeDataProvider', () => {
collapsibleState: 0,
label: matchingNote1.title,
description: '/path/ABC.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
{
collapsibleState: 0,
label: matchingNote2.title,
description: '/path-bis/XYZ.md',
command: { command: OPEN_COMMAND.command },
command: { command: 'vscode.open' },
},
]);
});

View File

@@ -1,13 +1,10 @@
import * as path from 'path';
import * as vscode from 'vscode';
import { GroupedResoucesConfigGroupBy } from '../settings';
import { getContainsTooltip, getNoteTooltip, isSome } from '../utils';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { toVsCodeUri } from './vsc-utils';
import { getContainsTooltip, isSome } from '../utils';
import { URI } from '../core/model/uri';
import { Resource } from '../core/model/note';
import { FoamWorkspace } from '../core/model/workspace';
import { IMatcher } from '../core/services/datastore';
import { UriTreeItem } from './tree-view-utils';
/**
* Provides the ability to expose a TreeDataExplorerView in VSCode. This class will
@@ -128,13 +125,14 @@ export class GroupedResourcesTreeDataProvider
return item;
}
getChildren(
directory?: DirectoryTreeItem
async getChildren(
item?: GroupedResourceTreeItem
): Promise<GroupedResourceTreeItem[]> {
if ((item as any)?.getChildren) {
const children = await (item as any).getChildren();
return children.sort(sortByTreeItemLabel);
}
if (this.groupBy === GroupedResoucesConfigGroupBy.Folder) {
if (isSome(directory)) {
return Promise.resolve(directory.children.sort(sortByTreeItemLabel));
}
const directories = Object.entries(this.getUrisByDirectory())
.sort(([dir1], [dir2]) => sortByString(dir1, dir2))
.map(
@@ -186,63 +184,6 @@ type UrisByDirectory = { [key: string]: Array<URI> };
type GroupedResourceTreeItem = UriTreeItem | DirectoryTreeItem;
export class UriTreeItem extends vscode.TreeItem {
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
} = {}
) {
super(options?.title ?? uri.getName(), options.collapsibleState);
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.command = {
command: OPEN_COMMAND.command,
title: OPEN_COMMAND.title,
arguments: [
{
uri: uri,
},
],
};
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<GroupedResourceTreeItem> {
return Promise.resolve(this);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
collapsibleState = vscode.TreeItemCollapsibleState.None
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState,
});
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class DirectoryTreeItem extends vscode.TreeItem {
constructor(
public readonly dir: string,
@@ -264,6 +205,10 @@ export class DirectoryTreeItem extends vscode.TreeItem {
this.tooltip = getContainsTooltip(titles);
return Promise.resolve(this);
}
getChildren(): Promise<GroupedResourceTreeItem[]> {
return Promise.resolve(this.children);
}
}
const sortByTreeItemLabel = (a: vscode.TreeItem, b: vscode.TreeItem) =>

View File

@@ -0,0 +1,162 @@
import * as vscode from 'vscode';
import { Resource } from '../core/model/note';
import { toVsCodeUri } from './vsc-utils';
import { Range } from '../core/model/range';
import { OPEN_COMMAND } from '../features/commands/open-resource';
import { URI } from '../core/model/uri';
import { FoamWorkspace } from '../core/model/workspace';
import { getNoteTooltip } from '../utils';
import { isSome } from '../core/utils';
import { groupBy } from 'lodash';
export class UriTreeItem extends vscode.TreeItem {
private doGetChildren: () => Promise<vscode.TreeItem[]>;
constructor(
public readonly uri: URI,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
icon?: string;
title?: string;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(
options?.title ?? uri.getName(),
options.collapsibleState
? options.collapsibleState
: options.getChildren
? vscode.TreeItemCollapsibleState.Collapsed
: vscode.TreeItemCollapsibleState.None
);
this.doGetChildren = options.getChildren;
this.description = uri.path.replace(
vscode.workspace.getWorkspaceFolder(toVsCodeUri(uri))?.uri.path,
''
);
this.tooltip = undefined;
this.iconPath = new vscode.ThemeIcon(options.icon ?? 'new-file');
}
resolveTreeItem(): Promise<UriTreeItem> {
return Promise.resolve(this);
}
getChildren(): Promise<vscode.TreeItem[]> {
return isSome(this.doGetChildren)
? this.doGetChildren()
: Promise.resolve([]);
}
}
export class ResourceTreeItem extends UriTreeItem {
constructor(
public readonly resource: Resource,
private readonly workspace: FoamWorkspace,
options: {
collapsibleState?: vscode.TreeItemCollapsibleState;
getChildren?: () => Promise<vscode.TreeItem[]>;
} = {}
) {
super(resource.uri, {
title: resource.title,
icon: 'note',
collapsibleState: options.collapsibleState,
getChildren: options.getChildren,
});
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri)],
title: 'Go to location',
};
this.contextValue = 'resource';
}
async resolveTreeItem(): Promise<ResourceTreeItem> {
if (this instanceof ResourceTreeItem) {
const content = await this.workspace.readAsMarkdown(this.resource.uri);
this.tooltip = isSome(content)
? getNoteTooltip(content)
: this.resource.title;
}
return this;
}
}
export class ResourceRangeTreeItem extends vscode.TreeItem {
constructor(
public label: string,
public readonly resource: Resource,
public readonly range: Range
) {
super(label, vscode.TreeItemCollapsibleState.None);
this.label = `${range.start.line}: ${this.label}`;
this.command = {
command: 'vscode.open',
arguments: [toVsCodeUri(resource.uri), { selection: range }],
title: 'Go to location',
};
}
resolveTreeItem(): Promise<ResourceRangeTreeItem> {
return Promise.resolve(this);
}
static async createStandardItem(
workspace: FoamWorkspace,
resource: Resource,
range: Range
): Promise<ResourceRangeTreeItem> {
const lines = ((await workspace.readAsMarkdown(resource.uri)) ?? '').split(
'\n'
);
const line = lines[range.start.line];
const start = Math.max(0, range.start.character - 15);
const ellipsis = start === 0 ? '' : '...';
const label = line
? `${range.start.line}: ${ellipsis}${line.slice(start, start + 300)}`
: Range.toString(range);
const tooltip = line && getNoteTooltip(line);
const item = new ResourceRangeTreeItem(label, resource, range);
item.tooltip = tooltip;
return item;
}
}
export const groupRangesByResource = async (
workspace: FoamWorkspace,
items:
| ResourceRangeTreeItem[]
| Promise<ResourceRangeTreeItem[]>
| Promise<ResourceRangeTreeItem>[],
collapsibleState = vscode.TreeItemCollapsibleState.Collapsed
) => {
let itemsArray = [] as ResourceRangeTreeItem[];
if (items instanceof Promise) {
itemsArray = await items;
}
if (items instanceof Array && items[0] instanceof Promise) {
itemsArray = await Promise.all(items);
}
if (items instanceof Array && items[0] instanceof ResourceRangeTreeItem) {
itemsArray = items as any;
}
const byResource = groupBy(itemsArray, item => item.resource.uri.path);
const resourceItems = Object.values(byResource).map(items => {
const resourceItem = new ResourceTreeItem(items[0].resource, workspace, {
collapsibleState,
getChildren: () => {
return Promise.resolve(
items.sort((a, b) => Range.isBefore(a.range, b.range))
);
},
});
resourceItem.description = `(${items.length}) ${resourceItem.description}`;
return resourceItem;
});
resourceItems.sort((a, b) => Resource.sortByTitle(a.resource, b.resource));
return resourceItems;
};

View File

@@ -5,7 +5,7 @@
👀*This is an early stage project under rapid development. For updates join the [Foam community Discord](https://foambubble.github.io/join-discord/g)! 💬*
<!-- ALL-CONTRIBUTORS-BADGE:START - Do not remove or modify this section -->
[![All Contributors](https://img.shields.io/badge/all_contributors-110-orange.svg?style=flat-square)](#contributors-)
[![All Contributors](https://img.shields.io/badge/all_contributors-111-orange.svg?style=flat-square)](#contributors-)
<!-- ALL-CONTRIBUTORS-BADGE:END -->
[![Visual Studio Marketplace Installs](https://img.shields.io/visual-studio-marketplace/i/foam.foam-vscode?label=VS%20Code%20Installs)](https://marketplace.visualstudio.com/items?itemName=foam.foam-vscode)
@@ -340,6 +340,7 @@ Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/d
<td align="center" valign="top" width="14.28%"><a href="https://github.com/badsketch"><img src="https://avatars.githubusercontent.com/u/8953212?v=4?s=60" width="60px;" alt="Daniel Wang"/><br /><sub><b>Daniel Wang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=badsketch" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://yongliangliu.com"><img src="https://avatars.githubusercontent.com/u/41845017?v=4?s=60" width="60px;" alt="Liu YongLiang"/><br /><sub><b>Liu YongLiang</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=tlylt" title="Documentation">📖</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://scottakerman.com"><img src="https://avatars.githubusercontent.com/u/15224439?v=4?s=60" width="60px;" alt="Scott Akerman"/><br /><sub><b>Scott Akerman</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=Skakerman" title="Code">💻</a></td>
<td align="center" valign="top" width="14.28%"><a href="http://www.jim-graham.net/"><img src="https://avatars.githubusercontent.com/u/430293?v=4?s=60" width="60px;" alt="Jim Graham"/><br /><sub><b>Jim Graham</b></sub></a><br /><a href="https://github.com/foambubble/foam/commits?author=jimgraham" title="Code">💻</a></td>
</tr>
</tbody>
</table>