Feature: sync links on file rename (#969)

* basic implementation of file rename support

* tweaks to various tests

* make lint happy again

* Improved reporting

* added setting related to file sync

* added documentation in readme
This commit is contained in:
Riccardo
2022-04-07 17:50:24 +02:00
committed by GitHub
parent 5b7a2ab022
commit a7af7689a4
22 changed files with 424 additions and 31 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -27,6 +27,12 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Sync links on file rename
Foam updates the links to renamed files, so your notes stay consistent.
![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 KiB

View File

@@ -255,6 +255,11 @@
"Disable wikilink definitions generation"
]
},
"foam.links.sync.enable": {
"description": "Enable synching links when moving/renaming notes",
"type": "boolean",
"default": true
},
"foam.links.hover.enable": {
"description": "Enable displaying note content on hover links",
"type": "boolean",
@@ -418,7 +423,8 @@
"tsdx": "^0.13.2",
"tslib": "^2.0.0",
"typescript": "^3.9.5",
"vscode-test": "^1.3.0"
"vscode-test": "^1.3.0",
"wait-for-expect": "^3.0.2"
},
"dependencies": {
"dateformat": "^3.0.3",
@@ -440,4 +446,4 @@
"unist-util-visit": "^2.0.2",
"yaml": "^1.10.0"
}
}
}

View File

@@ -71,5 +71,13 @@ describe('Foam URI', () => {
expect(URI.file('/my/file.markdown').resolve('../hello')).toEqual(
URI.file('/hello.markdown')
);
expect(
URI.file('/path/to/a/note.md').resolve('../another-note.md')
).toEqual(URI.file('/path/to/another-note.md'));
expect(
URI.file('/path/to/a/note.md').relativeTo(
URI.file('/path/to/another/note.md').getDirectory()
)
).toEqual(URI.file('../a/note.md'));
});
});

View File

@@ -167,4 +167,23 @@ describe('Identifier computation', () => {
const identifier = FoamWorkspace.getShortestIdentifier(needle, haystack);
expect(identifier).toEqual('project/car/todo');
});
it('should ignore elements from the exclude list', () => {
const workspace = new FoamWorkspace();
const noteA = createTestNote({ uri: '/path/to/note-a.md' });
const noteB = createTestNote({ uri: '/path/to/note-b.md' });
const noteC = createTestNote({ uri: '/path/to/note-c.md' });
const noteD = createTestNote({ uri: '/path/to/note-d.md' });
const noteABis = createTestNote({ uri: '/path/to/another/note-a.md' });
workspace
.set(noteA)
.set(noteB)
.set(noteC)
.set(noteD);
expect(workspace.getIdentifier(noteABis.uri)).toEqual('another/note-a');
expect(
workspace.getIdentifier(noteABis.uri, [noteB.uri, noteA.uri])
).toEqual('note-a');
});
});

View File

@@ -5,7 +5,6 @@ import { isSome } from '../utils';
import { Emitter } from '../common/event';
import { ResourceProvider } from './provider';
import { IDisposable } from '../common/lifecycle';
import { Logger } from '../utils/log';
export class FoamWorkspace implements IDisposable {
private onDidAddEmitter = new Emitter<Resource>();
@@ -83,17 +82,25 @@ export class FoamWorkspace implements IDisposable {
*
* @param forResource the resource to compute the identifier for
*/
public getIdentifier(forResource: URI): string {
public getIdentifier(forResource: URI, exclude?: URI[]): string {
const amongst = [];
const basename = forResource.getBasename();
for (const res of this._resources.values()) {
// Just a quick optimization to only add the elements that might match
if (res.uri.path.endsWith(basename)) {
if (!res.uri.isEqual(forResource)) {
amongst.push(res.uri);
}
// skip elements that cannot possibly match
if (!res.uri.path.endsWith(basename)) {
continue;
}
// skip self
if (res.uri.isEqual(forResource)) {
continue;
}
// skip exclude list
if (exclude && exclude.find(ex => ex.isEqual(res.uri))) {
continue;
}
amongst.push(res.uri);
}
let identifier = FoamWorkspace.getShortestIdentifier(
forResource.path,
amongst.map(uri => uri.path)

View File

@@ -96,7 +96,7 @@ describe('MarkdownLink', () => {
});
describe('rename wikilink', () => {
it.skip('should rename the target only', () => {
it('should rename the target only', () => {
const link = parser.parse(
getRandomURI(),
`this is a [[wikilink#section]]`

View File

@@ -2,7 +2,7 @@ import { ResourceLink } from '../model/note';
export abstract class MarkdownLink {
private static wikilinkRegex = new RegExp(
/\[\[([^#\|]+)?#?([^\|]+)?\|?(.*)?\]\]/
/\[\[([^#|]+)?#?([^|]+)?\|?(.*)?\]\]/
);
private static directLinkRegex = new RegExp(
/\[([^\]]+)\]\(([^#]*)?#?([^\]]+)?\)/
@@ -10,7 +10,7 @@ export abstract class MarkdownLink {
public static analyzeLink(link: ResourceLink) {
if (link.type === 'wikilink') {
const [_, target, section, alias] = this.wikilinkRegex.exec(link.rawText);
const [, target, section, alias] = this.wikilinkRegex.exec(link.rawText);
return {
target: target?.replace(/\\/g, ''),
section,
@@ -18,7 +18,7 @@ export abstract class MarkdownLink {
};
}
if (link.type === 'link') {
const [_, alias, target, section] = this.directLinkRegex.exec(
const [, alias, target, section] = this.directLinkRegex.exec(
link.rawText
);
return { target, section, alias };

View File

@@ -1,5 +1,4 @@
import { createMarkdownParser, ParserPlugin } from './markdown-parser';
import { ResourceLink } from '../model/note';
import { Logger } from '../utils/log';
import { URI } from '../model/uri';
import { Range } from '../model/range';

View File

@@ -150,7 +150,22 @@ describe('Link resolution', () => {
});
describe('Markdown direct links', () => {
it('should support absolute path', () => {
it('should support absolute path 1', () => {
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',
links: [{ to: '../../to/page-a.md' }],
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path 1', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: './another/page-b.md' }],
@@ -165,13 +180,13 @@ describe('Link resolution', () => {
expect(ws.resolveLink(noteA, noteA.links[0])).toEqual(noteB.uri);
});
it('should support relative path', () => {
it('should support relative path 2', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'more/page-c.md' }],
links: [{ to: 'more/page-b.md' }],
});
const noteB = createTestNote({
uri: '/path/to/more/page-c.md',
uri: '/path/to/more/page-b.md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);
@@ -181,10 +196,10 @@ describe('Link resolution', () => {
it('should default to relative path', () => {
const noteA = createTestNote({
uri: '/path/to/page-a.md',
links: [{ to: 'page c.md' }],
links: [{ to: 'page .md' }],
});
const noteB = createTestNote({
uri: '/path/to/page c.md',
uri: '/path/to/page .md',
});
const ws = createTestWorkspace();
ws.set(noteA).set(noteB);

View File

@@ -1,9 +1,5 @@
import { env, Position, Selection, commands } from 'vscode';
import {
createFile,
getUriInWorkspace,
showInEditor,
} from '../test/test-utils-vscode';
import { createFile, showInEditor } from '../test/test-utils-vscode';
describe('copyWithoutBrackets', () => {
it('should get the input from the active editor selection', async () => {

View File

@@ -18,9 +18,11 @@ import tagCompletionProvider from './tag-completion';
import linkDecorations from './document-decorator';
import navigationProviders from './navigation-provider';
import wikilinkDiagnostics from './wikilink-diagnostics';
import refactor from './refactor';
import { FoamFeature } from '../types';
export const features: FoamFeature[] = [
refactor,
navigationProviders,
wikilinkDiagnostics,
tagsExplorer,

View File

@@ -1,7 +1,6 @@
import * as vscode from 'vscode';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamGraph } from '../core/model/graph';
import { FoamWorkspace } from '../core/model/workspace';
import { createTestNote, createTestWorkspace } from '../test/test-utils';
import {
cleanWorkspace,

View File

@@ -11,7 +11,6 @@ import { NavigationProvider } from './navigation-provider';
import { OPEN_COMMAND } from './utility-commands';
import { toVsCodeUri } from '../utils/vsc-utils';
import { createMarkdownParser } from '../core/services/markdown-parser';
import { FoamWorkspace } from '../core/model/workspace';
import { FoamGraph } from '../core/model/graph';
describe('Document navigation', () => {
@@ -232,6 +231,6 @@ describe('Document navigation', () => {
range: new vscode.Range(0, 23, 0, 23 + 9),
});
});
// it('should provide references for placeholders', async () => {});
it.todo('should provide references for placeholders');
});
});

View File

@@ -0,0 +1,205 @@
import { wait, waitForExpect } from '../test/test-utils';
import {
closeEditors,
createFile,
cleanWorkspace,
readFile,
renameFile,
showInEditor,
} from '../test/test-utils-vscode';
describe('Note rename sync', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
afterAll(closeEditors);
describe('wikilinks', () => {
it('should sync wikilinks to renamed notes', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilinks',
'rename-note-a.md',
]);
const noteB = await createFile(
`Link to [[${noteA.name}]]. Also a [[placeholder]] and again [[${noteA.name}]]`,
['refactor', 'wikilinks', 'rename-note-b.md']
);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`, [
'refactor',
'wikilinks',
'rename-note-c.md',
]);
const { doc } = await showInEditor(noteB.uri);
const newName = 'renamed-note-a';
const newUri = noteA.uri.resolve(newName);
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
// check it updates documents open in editors
expect(doc.getText()).toEqual(
`Link to [[${newName}]]. Also a [[placeholder]] and again [[${newName}]]`
);
// and documents not open in editors
expect(await readFile(noteC.uri)).toEqual(
`Link to [[${newName}]] from note C.`
);
});
});
it('should use the best identifier based on the new note location', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
// rename note A
const newUri = noteA.uri.resolve('note-b.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText()).toEqual(`Link to [[first/note-b]] from note C.`);
});
});
it('should use the best identifier when moving the note to another directory', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'wikilink',
'first',
'note-a.md',
]);
await createFile(`Content of note B`, [
'refactor',
'wikilink',
'second',
'note-b.md',
]);
const noteC = await createFile(`Link to [[${noteA.name}]] from note C.`);
const { doc } = await showInEditor(noteC.uri);
const newUri = noteA.uri.resolve('../second/note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText()).toEqual(`Link to [[note-a]] from note C.`);
});
});
it('should keep the alias in wikilinks', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}|Alias]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText()).toEqual(`Link to [[new-note-a|Alias]]`);
});
});
it('should keep the section part of the wikilink', async () => {
const noteA = await createFile(`Content of note A`);
const noteB = await createFile(`Link to [[${noteA.name}#Section]]`);
const { doc } = await showInEditor(noteB.uri);
// rename note A
const newUri = noteA.uri.resolve('new-note-with-section.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText()).toEqual(
`Link to [[new-note-with-section#Section]]`
);
});
});
it('should sync when moving the note to a new folder', async () => {
const noteA = await createFile(`Content of note A`, [
'refactor',
'first',
'note-a.md',
]);
const noteC = await createFile(`Link to [[note-a]] from note C.`);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
const content = await readFile(noteC.uri);
await waitForExpect(async () => {
expect(content).toEqual(`Link to [[note-a]] from note C.`);
});
});
});
describe('direct links', () => {
beforeAll(async () => {
await closeEditors();
await cleanWorkspace();
});
beforeEach(closeEditors);
it('should rename relative direct links', async () => {
const noteA = await createFile(
`Content of note A. Lorem etc etc etc etc`,
['refactor', 'direct-links', 'f1', 'note-a.md']
);
const noteB = await createFile(
`Link to [note](../f1/note-a.md) from note B.`,
['refactor', 'direct-links', 'f2', 'note-b.md']
);
const { doc } = await showInEditor(noteB.uri);
const newUri = noteA.uri.resolve('../note-a.md');
// wait for workspace files to be added to graph (because of graph debounced update)
// TODO this should be replaced by either a force-refresh command or by Foam updating immediately in test mode
await wait(600);
await renameFile(noteA.uri, newUri);
await waitForExpect(async () => {
expect(doc.getText()).toEqual(
`Link to [note](../note-a.md) from note B.`
);
});
});
});
});

View File

@@ -0,0 +1,108 @@
import * as vscode from 'vscode';
import { Foam } from '../core/model/foam';
import { MarkdownLink } from '../core/services/markdown-link';
import { Logger } from '../core/utils/log';
import { isAbsolute } from '../core/utils/path';
import { getFoamVsCodeConfig } from '../services/config';
import { FoamFeature } from '../types';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
const feature: FoamFeature = {
activate: async (
context: vscode.ExtensionContext,
foamPromise: Promise<Foam>
) => {
const foam = await foamPromise;
context.subscriptions.push(
vscode.workspace.onWillRenameFiles(async e => {
if (!getFoamVsCodeConfig<boolean>('links.sync.enable')) {
return;
}
const renameEdits = new vscode.WorkspaceEdit();
e.files.forEach(({ oldUri, newUri }) => {
const connections = foam.graph.getBacklinks(fromVsCodeUri(oldUri));
connections.forEach(async connection => {
const { target } = MarkdownLink.analyzeLink(connection.link);
switch (connection.link.type) {
case 'wikilink': {
const identifier = foam.workspace.getIdentifier(
fromVsCodeUri(newUri),
[fromVsCodeUri(oldUri)]
);
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: identifier }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
case 'link': {
const path = isAbsolute(target)
? '/' + vscode.workspace.asRelativePath(newUri)
: fromVsCodeUri(newUri).relativeTo(
connection.source.getDirectory()
).path;
const edit = MarkdownLink.createUpdateLinkEdit(
connection.link,
{ target: path }
);
renameEdits.replace(
toVsCodeUri(connection.source),
toVsCodeRange(edit.selection),
edit.newText
);
break;
}
}
});
});
try {
if (renameEdits.size > 0) {
// We break the update by file because applying it at once was causing
// dirty state and editors not always saving or closing
for (const renameEditForUri of renameEdits.entries()) {
const [uri, edits] = renameEditForUri;
const fileEdits = new vscode.WorkspaceEdit();
fileEdits.set(uri, edits);
await vscode.workspace.applyEdit(fileEdits);
const editor = await vscode.workspace.openTextDocument(uri);
// Because the save happens within 50ms of opening the doc, it will be then closed
editor.save();
}
// Reporting
const nUpdates = renameEdits.entries().reduce((acc, entry) => {
return (acc += entry[1].length);
}, 0);
const links = nUpdates > 1 ? 'links' : 'link';
const nFiles = renameEdits.size;
const files = nFiles > 1 ? 'files' : 'file';
Logger.info(
`Updated links in the following files:`,
...renameEdits
.entries()
.map(e => vscode.workspace.asRelativePath(e[0]))
);
vscode.window.showInformationMessage(
`Updated ${nUpdates} ${links} across ${nFiles} ${files}.`
);
}
} catch (e) {
Logger.error('Error while updating references to file', e);
vscode.window.showErrorMessage(
`Foam couldn't update the links to ${vscode.workspace.asRelativePath(
e.newUri
)}. Check the logs for error details.`
);
}
})
);
},
};
export default feature;

View File

@@ -1,10 +1,9 @@
import * as vscode from 'vscode';
import { FoamFeature } from '../types';
import { URI } from '../core/model/uri';
import { fromVsCodeUri, toVsCodeRange, toVsCodeUri } from '../utils/vsc-utils';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { NoteFactory } from '../services/templates';
import { Foam } from '../core/model/foam';
import { Resource } from '../core/model/note';
export const OPEN_COMMAND = {
command: 'foam-vscode.open-resource',

View File

@@ -3,7 +3,7 @@
*/
import * as vscode from 'vscode';
import path from 'path';
import { TextEncoder } from 'util';
import { TextDecoder, TextEncoder } from 'util';
import { fromVsCodeUri, toVsCodeUri } from '../utils/vsc-utils';
import { Logger } from '../core/utils/log';
import { URI } from '../core/model/uri';
@@ -64,6 +64,18 @@ export const createFile = async (content: string, filepath: string[] = []) => {
return { uri, content, ...filenameComponents };
};
export const renameFile = (from: URI, to: URI) => {
const edit = new vscode.WorkspaceEdit();
edit.renameFile(toVsCodeUri(from), toVsCodeUri(to));
return vscode.workspace.applyEdit(edit);
};
const decoder = new TextDecoder('utf-8');
export const readFile = async (uri: URI) => {
const content = await vscode.workspace.fs.readFile(toVsCodeUri(uri));
return decoder.decode(content);
};
export const createNote = (r: Resource) => {
const content = `# ${r.title}

View File

@@ -9,6 +9,8 @@ import { Matcher } from '../core/services/datastore';
import { MarkdownResourceProvider } from '../core/services/markdown-provider';
import { NoteLinkDefinition, Resource } from '../core/model/note';
export { default as waitForExpect } from 'wait-for-expect';
Logger.setLevel('error');
export const TEST_DATA_DIR = URI.file(__dirname).joinPath(

View File

@@ -30,6 +30,12 @@ Foam helps you create the connections between your notes, and your placeholders
![Link Autocompletion](./assets/screenshots/feature-link-autocompletion.gif)
### Sync links on file rename
Foam updates the links to renamed files, so your notes stay consistent.
![Sync links on file rename](./assets/screenshots/feature-link-sync.gif)
### Unique identifiers across directories
Foam supports files with the same name in multiple directories.

View File

@@ -11229,6 +11229,11 @@ w3c-xmlserializer@^2.0.0:
dependencies:
xml-name-validator "^3.0.0"
wait-for-expect@^3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/wait-for-expect/-/wait-for-expect-3.0.2.tgz#d2f14b2f7b778c9b82144109c8fa89ceaadaa463"
integrity sha512-cfS1+DZxuav1aBYbaO/kE06EOS8yRw7qOFoD3XtjTkYvCvh3zUvNST8DXK/nPaeqIzIv3P3kL3lRJn8iwOiSag==
walker@^1.0.7, walker@~1.0.5:
version "1.0.7"
resolved "https://registry.yarnpkg.com/walker/-/walker-1.0.7.tgz#2f7f9b8fd10d677262b18a884e28d19618e028fb"