From 891c8fb995b4c451221566e15b42267061ff20ae Mon Sep 17 00:00:00 2001 From: Zef Hemel Date: Fri, 28 Jul 2023 15:20:56 +0200 Subject: [PATCH] Fixes #453: batch prefix refactor command --- plug-api/lib/page.ts | 12 +- plugs/core/core.plug.yaml | 24 ++-- plugs/core/page.ts | 236 +------------------------------------- plugs/core/page_links.ts | 143 +++++++++++++++++++++++ plugs/core/refactor.ts | 172 ++++++++++++++++++++++++++- web/client.ts | 11 +- web/editor_ui.tsx | 2 +- 7 files changed, 345 insertions(+), 255 deletions(-) create mode 100644 plugs/core/page_links.ts diff --git a/plug-api/lib/page.ts b/plug-api/lib/page.ts index 1d1ac00e..8e0fd53d 100644 --- a/plug-api/lib/page.ts +++ b/plug-api/lib/page.ts @@ -1,4 +1,12 @@ -export function isValidPageName(name: string): boolean { +export function validatePageName(name: string) { // Page can not be empty and not end with a file extension (e.g. "bla.md") - return name !== "" && !name.startsWith(".") && !/\.[a-zA-Z]+$/.test(name); + if (name === "") { + throw new Error("Page name can not be empty"); + } + if (name.startsWith(".")) { + throw new Error("Page name cannot start with a '.'"); + } + if (/\.[a-zA-Z]+$/.test(name)) { + throw new Error("Page name can not end with a file extension"); + } } diff --git a/plugs/core/core.plug.yaml b/plugs/core/core.plug.yaml index 9d5f4b39..a1e3fb76 100644 --- a/plugs/core/core.plug.yaml +++ b/plugs/core/core.plug.yaml @@ -76,20 +76,13 @@ functions: # Backlinks indexLinks: - path: "./page.ts:indexLinks" + path: "./page_links.ts:indexLinks" events: - page:index linkQueryProvider: - path: ./page.ts:linkQueryProvider + path: ./page_links.ts:linkQueryProvider events: - query:link - renamePage: - path: "./page.ts:renamePage" - command: - name: "Page: Rename" - mac: Cmd-Alt-r - key: Ctrl-Alt-r - page: "" pageComplete: path: "./page.ts:pageComplete" @@ -322,9 +315,20 @@ functions: # Refactoring Commands extractToPageCommand: - path: ./refactor.ts:extractToPage + path: ./refactor.ts:extractToPageCommand command: name: "Extract text to new page" + renamePageCommand: + path: "./refactor.ts:renamePageCommand" + command: + name: "Page: Rename" + mac: Cmd-Alt-r + key: Ctrl-Alt-r + page: "" + renamePrefixCommand: + path: "./refactor.ts:renamePrefixCommand" + command: + name: "Refactor: Batch Rename Page Prefix" # Plug manager updatePlugsCommand: diff --git a/plugs/core/page.ts b/plugs/core/page.ts index 564d1162..022ad8b4 100644 --- a/plugs/core/page.ts +++ b/plugs/core/page.ts @@ -1,7 +1,6 @@ import type { CompleteEvent, IndexEvent, - IndexTreeEvent, QueryProviderEvent, } from "$sb/app_event.ts"; import { @@ -13,136 +12,19 @@ import { import { events } from "$sb/plugos-syscall/mod.ts"; -import { findNodeOfType, traverseTree } from "$sb/lib/tree.ts"; import { applyQuery } from "$sb/lib/query.ts"; -import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; import { invokeFunction } from "$sb/silverbullet-syscall/system.ts"; -import { isValidPageName } from "$sb/lib/page.ts"; -import { extractAttributes } from "$sb/lib/attribute.ts"; +import { backlinkPrefix } from "./page_links.ts"; // Key space: -// l:toPage:pos => {name: pageName, inDirective: true} // meta: => metaJson -const backlinkPrefix = `l:`; - -export type BacklinkEntry = { - name: string; - alias?: string; - inDirective?: boolean; - asTemplate?: boolean; -}; -export async function indexLinks({ name, tree }: IndexTreeEvent) { - const backLinks: { key: string; value: BacklinkEntry }[] = []; - // [[Style Links]] - // console.log("Now indexing links for", name); - const pageMeta = await extractFrontmatter(tree); - const toplevelAttributes = await extractAttributes(tree, false); - if ( - Object.keys(pageMeta).length > 0 || - Object.keys(toplevelAttributes).length > 0 - ) { - for (const [k, v] of Object.entries(toplevelAttributes)) { - pageMeta[k] = v; - } - // Don't index meta data starting with $ - for (const key in pageMeta) { - if (key.startsWith("$")) { - delete pageMeta[key]; - } - } - // console.log("Extracted page meta data", pageMeta); - await index.set(name, "meta:", pageMeta); - } - - let directiveDepth = 0; - traverseTree(tree, (n): boolean => { - if (n.type === "DirectiveStart") { - directiveDepth++; - const pageRef = findNodeOfType(n, "PageRef")!; - if (pageRef) { - const pageRefName = pageRef.children![0].text!.slice(2, -2); - backLinks.push({ - key: `${backlinkPrefix}${pageRefName}:${pageRef.from! + 2}`, - value: { name, asTemplate: true }, - }); - } - const directiveText = n.children![0].text; - // #use or #import - if (directiveText) { - const match = /\[\[(.+)\]\]/.exec(directiveText); - if (match) { - const pageRefName = match[1]; - backLinks.push({ - key: `${backlinkPrefix}${pageRefName}:${ - n.from! + match.index! + 2 - }`, - value: { name, asTemplate: true }, - }); - } - } - - return true; - } - if (n.type === "DirectiveStop") { - directiveDepth--; - return true; - } - - if (n.type === "WikiLink") { - const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!; - const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias"); - let toPage = wikiLinkPage.children![0].text!; - if (toPage.includes("@")) { - toPage = toPage.split("@")[0]; - } - const blEntry: BacklinkEntry = { name }; - if (directiveDepth > 0) { - blEntry.inDirective = true; - } - if (wikiLinkAlias) { - blEntry.alias = wikiLinkAlias.children![0].text!; - } - backLinks.push({ - key: `${backlinkPrefix}${toPage}:${wikiLinkPage.from}`, - value: blEntry, - }); - return true; - } - return false; - }); - // console.log("Found", backLinks.length, "page link(s)"); - await index.batchSet(name, backLinks); -} - export async function pageQueryProvider({ query, }: QueryProviderEvent): Promise { return applyQuery(query, await space.listPages()); } -export async function linkQueryProvider({ - query, - pageName, -}: QueryProviderEvent): Promise { - const links: any[] = []; - for ( - const { value: blEntry, key } of await index.queryPrefix( - `${backlinkPrefix}${pageName}:`, - ) - ) { - const [, , pos] = key.split(":"); // Key: l:page:pos - if (!blEntry.inDirective) { - blEntry.inDirective = false; - } - if (!blEntry.asTemplate) { - blEntry.asTemplate = false; - } - links.push({ ...blEntry, pos }); - } - return applyQuery(query, links); -} - export async function deletePage() { const pageName = await editor.getCurrentPage(); if ( @@ -189,102 +71,6 @@ export async function copyPage() { await editor.navigate(newName); } -export async function renamePage(cmdDef: any) { - console.log("Got a target name", cmdDef.page); - const oldName = await editor.getCurrentPage(); - const cursor = await editor.getCursor(); - console.log("Old name is", oldName); - const newName = cmdDef.page || - await editor.prompt(`Rename ${oldName} to:`, oldName); - if (!newName) { - return; - } - - if (!isValidPageName(newName)) { - return editor.flashNotification( - "Invalid page name: page names cannot end with a file extension nor start with a '.'", - "error", - ); - } - - console.log("New name", newName); - - if (newName.trim() === oldName.trim()) { - // Nothing to do here - console.log("Name unchanged, exiting"); - 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; - } - } - - const pagesToUpdate = await getBackLinks(oldName); - console.log("All pages containing backlinks", pagesToUpdate); - - const text = await editor.getText(); - - console.log("Writing new page to space"); - const newPageMeta = await space.writePage(newName, text); - console.log("Navigating to new page"); - await editor.navigate(newName, cursor, 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... - console.log("Deleting page from space"); - await space.deletePage(oldName); - } - - const pageToUpdateSet = new Set(); - for (const pageToUpdate of pagesToUpdate) { - pageToUpdateSet.add(pageToUpdate.page); - } - - let updatedReferences = 0; - - for (const pageToUpdate of pageToUpdateSet) { - if (pageToUpdate === oldName) { - continue; - } - console.log("Now going to update links in", pageToUpdate); - const text = await space.readPage(pageToUpdate); - // console.log("Received text", text); - if (!text) { - // Page likely does not exist, but at least we can skip it - continue; - } - - const newText = text.replaceAll(`[[${oldName}]]`, () => { - updatedReferences++; - return `[[${newName}]]`; - }).replaceAll(`[[${oldName}@`, () => { - updatedReferences++; - return `[[${newName}@`; - }); - if (text !== newText) { - console.log("Changes made, saving..."); - await space.writePage(pageToUpdate, newText); - } - } - await editor.flashNotification( - `Renamed page, and updated ${updatedReferences} references`, - ); -} - export async function newPageCommand() { const allPages = await space.listPages(); let pageName = `Untitled`; @@ -296,26 +82,6 @@ export async function newPageCommand() { await editor.navigate(pageName); } -type BackLink = { - page: string; - pos: number; -}; - -async function getBackLinks(pageName: string): Promise { - const allBackLinks = await index.queryPrefix( - `${backlinkPrefix}${pageName}:`, - ); - const pagesToUpdate: BackLink[] = []; - for (const { key, value: { name } } of allBackLinks) { - const keyParts = key.split(":"); - pagesToUpdate.push({ - page: name, - pos: +keyParts[keyParts.length - 1], - }); - } - return pagesToUpdate; -} - export async function reindexCommand() { await editor.flashNotification("Reindexing..."); await reindexSpace(); diff --git a/plugs/core/page_links.ts b/plugs/core/page_links.ts new file mode 100644 index 00000000..bce6f415 --- /dev/null +++ b/plugs/core/page_links.ts @@ -0,0 +1,143 @@ +import { index } from "$sb/silverbullet-syscall/mod.ts"; +import { findNodeOfType, traverseTree } from "$sb/lib/tree.ts"; +import { extractFrontmatter } from "$sb/lib/frontmatter.ts"; +import { extractAttributes } from "$sb/lib/attribute.ts"; +import { IndexTreeEvent, QueryProviderEvent } from "$sb/app_event.ts"; +import { applyQuery } from "$sb/lib/query.ts"; + +// Key space: +// l:toPage:pos => {name: pageName, inDirective: true, asTemplate: true} + +export const backlinkPrefix = `l:`; + +export type BacklinkEntry = { + name: string; + alias?: string; + inDirective?: boolean; + asTemplate?: boolean; +}; + +export async function indexLinks({ name, tree }: IndexTreeEvent) { + const backLinks: { key: string; value: BacklinkEntry }[] = []; + // [[Style Links]] + // console.log("Now indexing links for", name); + const pageMeta = await extractFrontmatter(tree); + const toplevelAttributes = await extractAttributes(tree, false); + if ( + Object.keys(pageMeta).length > 0 || + Object.keys(toplevelAttributes).length > 0 + ) { + for (const [k, v] of Object.entries(toplevelAttributes)) { + pageMeta[k] = v; + } + // Don't index meta data starting with $ + for (const key in pageMeta) { + if (key.startsWith("$")) { + delete pageMeta[key]; + } + } + // console.log("Extracted page meta data", pageMeta); + await index.set(name, "meta:", pageMeta); + } + + let directiveDepth = 0; + traverseTree(tree, (n): boolean => { + if (n.type === "DirectiveStart") { + directiveDepth++; + const pageRef = findNodeOfType(n, "PageRef")!; + if (pageRef) { + const pageRefName = pageRef.children![0].text!.slice(2, -2); + backLinks.push({ + key: `${backlinkPrefix}${pageRefName}:${pageRef.from! + 2}`, + value: { name, asTemplate: true }, + }); + } + const directiveText = n.children![0].text; + // #use or #import + if (directiveText) { + const match = /\[\[(.+)\]\]/.exec(directiveText); + if (match) { + const pageRefName = match[1]; + backLinks.push({ + key: `${backlinkPrefix}${pageRefName}:${ + n.from! + match.index! + 2 + }`, + value: { name, asTemplate: true }, + }); + } + } + + return true; + } + if (n.type === "DirectiveStop") { + directiveDepth--; + return true; + } + + if (n.type === "WikiLink") { + const wikiLinkPage = findNodeOfType(n, "WikiLinkPage")!; + const wikiLinkAlias = findNodeOfType(n, "WikiLinkAlias"); + let toPage = wikiLinkPage.children![0].text!; + if (toPage.includes("@")) { + toPage = toPage.split("@")[0]; + } + const blEntry: BacklinkEntry = { name }; + if (directiveDepth > 0) { + blEntry.inDirective = true; + } + if (wikiLinkAlias) { + blEntry.alias = wikiLinkAlias.children![0].text!; + } + backLinks.push({ + key: `${backlinkPrefix}${toPage}:${wikiLinkPage.from}`, + value: blEntry, + }); + return true; + } + return false; + }); + // console.log("Found", backLinks.length, "page link(s)"); + await index.batchSet(name, backLinks); +} + +export async function linkQueryProvider({ + query, + pageName, +}: QueryProviderEvent): Promise { + const links: any[] = []; + for ( + const { value: blEntry, key } of await index.queryPrefix( + `${backlinkPrefix}${pageName}:`, + ) + ) { + const [, , pos] = key.split(":"); // Key: l:page:pos + if (!blEntry.inDirective) { + blEntry.inDirective = false; + } + if (!blEntry.asTemplate) { + blEntry.asTemplate = false; + } + links.push({ ...blEntry, pos }); + } + return applyQuery(query, links); +} + +type BackLinkPage = { + page: string; + pos: number; +}; + +export async function getBackLinks(pageName: string): Promise { + const allBackLinks = await index.queryPrefix( + `${backlinkPrefix}${pageName}:`, + ); + const pagesToUpdate: BackLinkPage[] = []; + for (const { key, value: { name } } of allBackLinks) { + const keyParts = key.split(":"); + pagesToUpdate.push({ + page: name, + pos: +keyParts[keyParts.length - 1], + }); + } + return pagesToUpdate; +} diff --git a/plugs/core/refactor.ts b/plugs/core/refactor.ts index b6f7164d..3a0ab6a5 100644 --- a/plugs/core/refactor.ts +++ b/plugs/core/refactor.ts @@ -1,6 +1,176 @@ import { editor, space } from "$sb/silverbullet-syscall/mod.ts"; +import { validatePageName } from "$sb/lib/page.ts"; +import { getBackLinks } from "./page_links.ts"; -export async function extractToPage() { +export async function renamePageCommand(cmdDef: any) { + const oldName = await editor.getCurrentPage(); + console.log("Old name is", oldName); + const newName = cmdDef.page || + await editor.prompt(`Rename ${oldName} to:`, oldName); + if (!newName) { + return; + } + + try { + validatePageName(newName); + } catch (e: any) { + return editor.flashNotification(e.message, "error"); + } + + console.log("New name", newName); + + if (newName.trim() === oldName.trim()) { + // Nothing to do here + console.log("Name unchanged, exiting"); + return; + } + + await editor.save(); + + try { + console.log( + "Checking if target page already exists, this should result in a 'Not found' error", + ); + 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 { + throw e; + } + } + const updatedReferences = await renamePage(oldName, newName); + console.log("Navigating to new page"); + await editor.navigate(newName, 0, true); + + await editor.flashNotification( + `Renamed page, and updated ${updatedReferences} references`, + ); + } catch (e: any) { + await editor.flashNotification(e.message, "error"); + } +} + +async function renamePage(oldName: string, newName: string): Promise { + const text = await space.readPage(oldName); + + console.log("Writing new page to space"); + const newPageMeta = await space.writePage(newName, text); + + const pagesToUpdate = await getBackLinks(oldName); + console.log("All pages containing backlinks", pagesToUpdate); + + // 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... + console.log("Deleting page from space"); + await space.deletePage(oldName); + } + + // This is the bit where we update all the links + const pageToUpdateSet = new Set(); + for (const pageToUpdate of pagesToUpdate) { + pageToUpdateSet.add(pageToUpdate.page); + } + + let updatedReferences = 0; + + for (const pageToUpdate of pageToUpdateSet) { + if (pageToUpdate === oldName) { + continue; + } + console.log("Now going to update links in", pageToUpdate); + const text = await space.readPage(pageToUpdate); + // console.log("Received text", text); + if (!text) { + // Page likely does not exist, but at least we can skip it + continue; + } + + const newText = text.replaceAll(`[[${oldName}]]`, () => { + updatedReferences++; + return `[[${newName}]]`; + }).replaceAll(`[[${oldName}@`, () => { + updatedReferences++; + return `[[${newName}@`; + }); + if (text !== newText) { + console.log("Changes made, saving..."); + await space.writePage(pageToUpdate, newText); + } + } + + return updatedReferences; +} + +export async function renamePrefixCommand() { + const oldPrefix = await editor.prompt("Prefix to rename:", ""); + if (!oldPrefix) { + return; + } + const newPrefix = await editor.prompt("New prefix:", oldPrefix); + if (!newPrefix) { + return; + } + + const allPages = await space.listPages(); + const allAffectedPages = allPages.map((page) => page.name).filter((page) => + page.startsWith(oldPrefix) + ); + + if ( + !(await editor.confirm( + `This will affect ${allAffectedPages.length} pages. Are you sure?`, + )) + ) { + return; + } + + const allNewNames = allAffectedPages.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.replace(oldPrefix, newPrefix) + ); + + try { + console.log("Pre-flight check to see if all new names are available"); + await Promise.all(allNewNames.map(async (name) => { + try { + await space.getPageMeta(name); + // If we got here, the page exists, so we error out + throw Error( + `Target ${name} already exists, cannot perform batch rename when one of the target pages already exists.`, + ); + } catch (e: any) { + if (e.message === "Not found") { + // Expected not found error, so we can continue + } else { + throw e; + } + } + })); + + console.log("All new names are available, proceeding with rename"); + for (let i = 0; i < allAffectedPages.length; i++) { + const oldName = allAffectedPages[i]; + const newName = allNewNames[i]; + console.log("Now renaming", oldName, "to", newName); + await renamePage(oldName, newName); + } + + await editor.flashNotification("Batch rename complete", "info"); + } catch (e: any) { + return editor.flashNotification(e.message, "error"); + } +} + +export async function extractToPageCommand() { const newName = await editor.prompt(`New page title:`, "new page"); if (!newName) { return; diff --git a/web/client.ts b/web/client.ts index 808c895b..d6c80c37 100644 --- a/web/client.ts +++ b/web/client.ts @@ -28,7 +28,7 @@ import { SyncStatus } from "../common/spaces/sync.ts"; import { HttpSpacePrimitives } from "../common/spaces/http_space_primitives.ts"; import { FallbackSpacePrimitives } from "../common/spaces/fallback_space_primitives.ts"; import { FilteredSpacePrimitives } from "../common/spaces/filtered_space_primitives.ts"; -import { isValidPageName } from "$sb/lib/page.ts"; +import { validatePageName } from "$sb/lib/page.ts"; import { ClientSystem } from "./client_system.ts"; import { createEditorState } from "./editor_state.ts"; import { OpenPages } from "./open_pages.ts"; @@ -613,11 +613,10 @@ export class Client { name = this.settings!.indexPage; } - if (!isValidPageName(name)) { - return this.flashNotification( - "Invalid page name: page names cannot end with a file extension nor start with a '.'", - "error", - ); + try { + validatePageName(name); + } catch (e: any) { + return this.flashNotification(e.message, "error"); } if (newWindow) { diff --git a/web/editor_ui.tsx b/web/editor_ui.tsx index a0ed5732..e0a18b82 100644 --- a/web/editor_ui.tsx +++ b/web/editor_ui.tsx @@ -196,7 +196,7 @@ export class MainUI { } console.log("Now renaming page to...", newName); await editor.system.system.loadedPlugs.get("core")!.invoke( - "renamePage", + "renamePageCommand", [{ page: newName }], ); editor.focus();