414 lines
14 KiB
TypeScript
414 lines
14 KiB
TypeScript
import { editor, markdown, space } from "@silverbulletmd/silverbullet/syscalls";
|
|
import { validatePageName } from "@silverbulletmd/silverbullet/lib/page_ref";
|
|
import { getBackLinks, type LinkObject } from "./page_links.ts";
|
|
import { queryObjects } from "./api.ts";
|
|
import {
|
|
absoluteToRelativePath,
|
|
folderName,
|
|
} from "@silverbulletmd/silverbullet/lib/resolve";
|
|
import type { ObjectValue } from "@silverbulletmd/silverbullet/types";
|
|
import {
|
|
addParentPointers,
|
|
findParentMatching,
|
|
nodeAtPos,
|
|
} from "@silverbulletmd/silverbullet/lib/tree";
|
|
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree";
|
|
import { findNodeOfType } from "@silverbulletmd/silverbullet/lib/tree";
|
|
|
|
/**
|
|
* Renames a single page.
|
|
* @param cmdDef Optional command arguments
|
|
* @param cmdDef.oldPage The current name of the page to rename. Defaults to
|
|
* the current page selected in the editor.
|
|
* @param cmdDef.page The name to rename the page to. If not provided the
|
|
* user will be prompted to enter a new name.
|
|
* @returns True if the rename succeeded; otherwise, false.
|
|
*/
|
|
export async function renamePageCommand(cmdDef: any) {
|
|
const oldName: string = cmdDef.oldPage || await editor.getCurrentPage();
|
|
const newName: string = cmdDef.page ||
|
|
await editor.prompt(`Rename ${oldName} to:`, oldName);
|
|
if (!newName) {
|
|
return false;
|
|
}
|
|
const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
|
|
await batchRenameFiles(pageList);
|
|
return true;
|
|
}
|
|
|
|
export async function renamePageLinkCommand() {
|
|
const mdTree = await markdown.parseMarkdown(await editor.getText());
|
|
const link = nodeAtPos(mdTree, await editor.getCursor());
|
|
if (!link) {
|
|
console.error("No link found at cursor position...");
|
|
return;
|
|
}
|
|
console.log("Link node", mdTree);
|
|
addParentPointers(mdTree);
|
|
let node: ParseTree | null = link;
|
|
if (node.type !== "WikiLink") {
|
|
node = findParentMatching(node, (t) => t.type === "WikiLink");
|
|
if (!node) {
|
|
console.error("No link found at cursor position");
|
|
return;
|
|
}
|
|
}
|
|
const wikiLinkPage = findNodeOfType(node, "WikiLinkPage");
|
|
if (!wikiLinkPage) {
|
|
console.error("No link found at cursor position");
|
|
return;
|
|
}
|
|
const oldName = wikiLinkPage.children![0].text!;
|
|
|
|
const newName = await editor.prompt(`Rename ${oldName} to:`, oldName);
|
|
if (!newName) {
|
|
return false;
|
|
}
|
|
const pageList: [string, string][] = [[oldName + ".md", newName + ".md"]];
|
|
await batchRenameFiles(pageList);
|
|
}
|
|
|
|
/**
|
|
* Renames any amount of files.
|
|
* If renaming pages, names should be passed with a .md extension
|
|
* @param fileList An array of tuples containing [FileToBeRenamed, NewFileName]
|
|
* @returns True if the rename succeeded; otherwise, false.
|
|
*/
|
|
export async function batchRenameFiles(fileList: [string, string][]) {
|
|
await editor.save();
|
|
|
|
// Skip unchanged names
|
|
fileList = fileList.filter(([oldName, newName]) => {
|
|
if (oldName.trim() === newName.trim()) {
|
|
console.log(`${oldName}'s name unchanged, skipping`);
|
|
} else {
|
|
return [oldName, newName];
|
|
}
|
|
});
|
|
|
|
try {
|
|
// Pre-flight checks
|
|
await Promise.all(fileList.map(async ([_oldName, newName]) => {
|
|
try {
|
|
if (newName.endsWith(".md")) {
|
|
validatePageName(newName.slice(0, -3));
|
|
// New name is valid
|
|
}
|
|
// Check if target file already exists
|
|
await space.getFileMeta(newName);
|
|
// If we got here, the file exists, so we error out
|
|
throw new Error(
|
|
`${newName} already exists, cannot rename to existing file.`,
|
|
);
|
|
} catch (e: any) {
|
|
if (e.message === "Not found") {
|
|
// Expected not found error, so we can continue
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}));
|
|
|
|
// All new names are available, proceeding with rename
|
|
for (const [oldName, newName] of fileList) {
|
|
console.log("Renaming", oldName, "to", newName);
|
|
try {
|
|
if (newName.endsWith(".md")) {
|
|
await renamePage(oldName.slice(0, -3), newName.slice(0, -3));
|
|
} else {
|
|
await renameAttachment(oldName, newName);
|
|
}
|
|
} catch (e: any) {
|
|
if (e.message === "Not found") {
|
|
console.log(`${oldName} does not exist, skipping`);
|
|
} else {
|
|
throw e;
|
|
}
|
|
}
|
|
}
|
|
|
|
return true;
|
|
} catch (e: any) {
|
|
await editor.flashNotification(e.message, "error");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Rename a page, update any backlinks and linked attachments
|
|
async function renamePage(oldName: string, newName: string) {
|
|
let text = await space.readPage(oldName);
|
|
|
|
// Update relative links and attachments on this page
|
|
const oldFolder = folderName(oldName);
|
|
const newFolder = folderName(newName);
|
|
const attachmentsToMove = new Set<string>();
|
|
// Links only need to be updated if the folder changes
|
|
if (oldFolder !== newFolder) {
|
|
const linksInPage = await queryObjects<LinkObject>("link", {
|
|
filter: ["=", ["attr", "page"], ["string", oldName]],
|
|
});
|
|
|
|
const linksToUpdate: ObjectValue<LinkObject>[] = [];
|
|
for (const link of linksInPage) {
|
|
if (link.toFile && folderName(link.toFile) === oldFolder) {
|
|
const attBackLinks = await getBackLinks(link.toFile);
|
|
if (attBackLinks.filter((a) => a.page !== oldName).length === 0) {
|
|
// Attachments is in the same folder as the page
|
|
// and is only linked to on this page, move it along with the page
|
|
attachmentsToMove.add(link.toFile);
|
|
continue;
|
|
}
|
|
}
|
|
linksToUpdate.push(link);
|
|
}
|
|
|
|
// Sort links by position
|
|
linksToUpdate.sort((a, b) => {
|
|
// Backwards to prevent errors from position changes
|
|
return b.pos - a.pos;
|
|
});
|
|
|
|
for (const link of linksToUpdate) {
|
|
let newLink = link.toPage || link.toFile!;
|
|
let newTail = text.substring(link.pos);
|
|
|
|
// Only relative links need to be updated
|
|
if (/^[^/][^\]]+?(?<!]])\)/.test(newTail)) {
|
|
newLink = absoluteToRelativePath(newName, newLink);
|
|
newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);
|
|
// Wrap in <> if link has spaces
|
|
if (newLink.includes(" ")) {
|
|
newTail = "<" + newTail.replace(")", ">)");
|
|
}
|
|
text = text.substring(0, link.pos) + newTail;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Write the new page
|
|
const newPageMeta = await space.writePage(newName, text);
|
|
|
|
// Move attachements along with page
|
|
const batchRenameAttachments: [string, string][] = [];
|
|
for (const att of attachmentsToMove) {
|
|
const newAttName = oldFolder.length === 0
|
|
? newFolder + "/" + att
|
|
: att.replace(oldFolder, newFolder).replace(/^\//, "");
|
|
batchRenameAttachments.push([att, newAttName]);
|
|
}
|
|
if (batchRenameAttachments.length > 0) {
|
|
await batchRenameFiles(batchRenameAttachments);
|
|
}
|
|
|
|
// Navigate to new page if currently viewing old page
|
|
if (await editor.getCurrentPage() === oldName) {
|
|
await editor.navigate({ page: newName, pos: 0 }, true);
|
|
}
|
|
// Handling the edge case of a changing page name just in casing on a case insensitive FS
|
|
const oldPageMeta = await space.getPageMeta(oldName);
|
|
if (oldPageMeta.lastModified !== newPageMeta.lastModified) {
|
|
// If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
|
|
await space.deletePage(oldName);
|
|
}
|
|
|
|
// Update backlinks to this page
|
|
const updatedRefences = await updateBacklinks(oldName, newName);
|
|
|
|
let message = `Renamed ${oldName} to ${newName}`;
|
|
if (updatedRefences > 0) {
|
|
message = `${message}, updated ${updatedRefences} backlinks`;
|
|
}
|
|
if (attachmentsToMove.size > 0) {
|
|
message = `${message}, moved ${attachmentsToMove.size} attachments`;
|
|
}
|
|
await editor.flashNotification(message, "info");
|
|
}
|
|
|
|
// Rename an attachment and update any backlinks
|
|
async function renameAttachment(
|
|
oldName: string,
|
|
newName: string,
|
|
) {
|
|
// Move the file
|
|
const oldFile = await space.readAttachment(oldName);
|
|
const newFileMeta = await space.writeAttachment(newName, oldFile);
|
|
|
|
// Handling the edge case of a changing file name just in casing on a case insensitive FS
|
|
const oldFileMeta = await space.getAttachmentMeta(oldName);
|
|
if (oldFileMeta.lastModified !== newFileMeta.lastModified) {
|
|
// If they're the same, let's assume it's the same file (case insensitive FS) and not delete, otherwise...
|
|
await space.deleteAttachment(oldName);
|
|
}
|
|
|
|
// Update any backlinks
|
|
const updatedRefences = await updateBacklinks(oldName, newName);
|
|
let message = `Renamed ${oldName} to ${newName}`;
|
|
if (updatedRefences > 0) {
|
|
message = `${message}, updated ${updatedRefences} backlinks`;
|
|
}
|
|
await editor.flashNotification(message, "info");
|
|
}
|
|
|
|
/**
|
|
* Renames pages based on a prefix string.
|
|
* @param cmdDef Optional command arguments
|
|
* @param cmdDef.oldPrefix The prefix to rename from. If not provided the
|
|
* user will be prompted to enter a prefix.
|
|
* @param cmdDef.newPrefix The prefix with which to replace the `oldPrefix`
|
|
* value. If not provided the user will be prompted to enter a new prefix.
|
|
* @param cmdDef.disableConfirmation If false, the user will be prompted
|
|
* to confirm the rename action; Otherwise no confirmation dialog will
|
|
* be shown before renaming. Defaults to false.
|
|
* @returns True if the rename succeeded; otherwise, false.
|
|
*/
|
|
export async function renamePrefixCommand(cmdDef: any) {
|
|
const oldPrefix = cmdDef.oldPrefix ??
|
|
await editor.prompt("Prefix to rename:", "");
|
|
if (!oldPrefix) {
|
|
return false;
|
|
}
|
|
|
|
const newPrefix = cmdDef.newPrefix ??
|
|
await editor.prompt("New prefix:", oldPrefix);
|
|
if (!newPrefix) {
|
|
return false;
|
|
}
|
|
|
|
const allAttachments = await space.listAttachments();
|
|
const allPages = await space.listPages();
|
|
let allAffectedFiles = allAttachments.map((file) => file.name).filter((
|
|
file,
|
|
) => file.startsWith(oldPrefix));
|
|
allAffectedFiles = allAffectedFiles.concat(
|
|
allPages.map((page) => page.name + ".md").filter((page) =>
|
|
page.startsWith(oldPrefix)
|
|
),
|
|
);
|
|
|
|
if (
|
|
cmdDef.disableConfirmation !== true && !(await editor.confirm(
|
|
`This will affect ${allAffectedFiles.length} files. Are you sure?`,
|
|
))
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
const allNewNames: [string, string][] = allAffectedFiles.map((name) => // This may seem naive, but it's actually fine, because we're only renaming the first occurrence (which will be the prefix)
|
|
[name, name.replace(oldPrefix, newPrefix)]);
|
|
await batchRenameFiles(allNewNames);
|
|
}
|
|
|
|
export async function extractToPageCommand() {
|
|
const selection = await editor.getSelection();
|
|
let text = await editor.getText();
|
|
text = text.slice(selection.from, selection.to);
|
|
|
|
const match = text.match("#{1,6}\\s+([^\n]*)");
|
|
|
|
let newName;
|
|
if (match) {
|
|
newName = match[1];
|
|
} else {
|
|
newName = "new page";
|
|
}
|
|
newName = await editor.prompt(`New page title:`, newName);
|
|
if (!newName) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
// This throws an error if the page does not exist, which we expect to be the case
|
|
await space.getPageMeta(newName);
|
|
// So when we get to this point, we error out
|
|
throw new Error(
|
|
`Page ${newName} already exists, cannot rename to existing page.`,
|
|
);
|
|
} catch (e: any) {
|
|
if (e.message === "Not found") {
|
|
// Expected not found error, so we can continue
|
|
} else {
|
|
await editor.flashNotification(e.message, "error");
|
|
throw e;
|
|
}
|
|
}
|
|
await editor.replaceRange(selection.from, selection.to, `[[${newName}]]`);
|
|
console.log("Writing new page to space");
|
|
await space.writePage(newName, text);
|
|
console.log("Navigating to new page");
|
|
await editor.navigate({ page: newName });
|
|
}
|
|
|
|
/**
|
|
* Updates backlinks across all pages
|
|
* @param oldName Full path to old page/file
|
|
* @param newName Full path to new page/file
|
|
* @returns The number of references updated
|
|
*/
|
|
async function updateBacklinks(
|
|
oldName: string,
|
|
newName: string,
|
|
): Promise<number> {
|
|
// This is the bit where we update all the links
|
|
const backLinks = await getBackLinks(oldName);
|
|
let updatedReferences = 0;
|
|
|
|
// Group by page to edit entire page at once
|
|
const backLinksByPage = backLinks.reduce(
|
|
(group: Record<string, LinkObject[]>, link) => {
|
|
const { page } = link;
|
|
group[page] = group[page] ?? [];
|
|
group[page].push(link);
|
|
return group;
|
|
},
|
|
{},
|
|
);
|
|
|
|
console.log("All pages containing backlinks", backLinks);
|
|
for (const [pageToEdit, linksInPage] of Object.entries(backLinksByPage)) {
|
|
if (pageToEdit === oldName) {
|
|
continue;
|
|
}
|
|
|
|
let text = await space.readPage(pageToEdit);
|
|
if (!text) {
|
|
// Page likely does not exist, but at least we can skip it
|
|
continue;
|
|
}
|
|
|
|
// Use indexed positions to replace links
|
|
linksInPage.sort((a, b) => {
|
|
// Backwards to prevent errors from position changes
|
|
return b.pos - a.pos;
|
|
});
|
|
|
|
for (const link of linksInPage) {
|
|
let newTail = text.substring(link.pos);
|
|
let newLink = newName;
|
|
if (/^[^\]]+?(?<!]])\)/.test(newTail)) {
|
|
// Is [Markdown link]()
|
|
if (newTail.startsWith("/") || newTail.startsWith("</")) {
|
|
// Is absolute mdlink, update with full path with leading /
|
|
newLink = "/" + newLink;
|
|
} else {
|
|
// Is relative mdlink
|
|
newLink = absoluteToRelativePath(pageToEdit, newLink);
|
|
}
|
|
newTail = newTail.replace(/^.*?(?=@\d*|#|\$|\))/, newLink);
|
|
|
|
// Wrap in <> if link has spaces
|
|
if (newLink.includes(" ")) {
|
|
newTail = "<" + newTail.replace(")", ">)");
|
|
}
|
|
} else {
|
|
// Is wikilink, replace with full path
|
|
newTail = newLink + newTail.slice(oldName.length);
|
|
}
|
|
|
|
text = text.substring(0, link.pos) + newTail;
|
|
updatedReferences++;
|
|
}
|
|
await space.writePage(pageToEdit, text);
|
|
}
|
|
return updatedReferences;
|
|
}
|