2024-08-18 21:24:18 +08:00
import { editor , markdown , space } from "@silverbulletmd/silverbullet/syscalls" ;
2024-08-07 02:11:38 +08:00
import { validatePageName } from "@silverbulletmd/silverbullet/lib/page_ref" ;
2024-07-30 23:33:33 +08:00
import { getBackLinks , type LinkObject } from "./page_links.ts" ;
2024-05-28 02:33:41 +08:00
import { queryObjects } from "./api.ts" ;
2024-08-07 02:11:38 +08:00
import {
absoluteToRelativePath ,
folderName ,
} from "@silverbulletmd/silverbullet/lib/resolve" ;
import type { ObjectValue } from "@silverbulletmd/silverbullet/types" ;
2024-08-18 21:24:18 +08:00
import {
addParentPointers ,
findParentMatching ,
nodeAtPos ,
} from "@silverbulletmd/silverbullet/lib/tree" ;
import type { ParseTree } from "@silverbulletmd/silverbullet/lib/tree" ;
import { findNodeOfType } from "@silverbulletmd/silverbullet/lib/tree" ;
2023-01-05 22:37:08 +08:00
2024-02-18 04:22:51 +08:00
/ * *
* 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 .
* /
2023-07-28 21:20:56 +08:00
export async function renamePageCommand ( cmdDef : any ) {
2024-05-28 02:33:41 +08:00
const oldName : string = cmdDef . oldPage || await editor . getCurrentPage ( ) ;
const newName : string = cmdDef . page ||
2023-07-28 21:20:56 +08:00
await editor . prompt ( ` Rename ${ oldName } to: ` , oldName ) ;
if ( ! newName ) {
2024-02-18 04:22:51 +08:00
return false ;
2023-07-28 21:20:56 +08:00
}
2024-05-28 02:33:41 +08:00
const pageList : [ string , string ] [ ] = [ [ oldName + ".md" , newName + ".md" ] ] ;
await batchRenameFiles ( pageList ) ;
return true ;
}
2024-08-18 21:24:18 +08:00
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 ) ;
}
2024-05-28 02:33:41 +08:00
/ * *
* 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 ] ;
}
} ) ;
2023-07-28 21:20:56 +08:00
try {
2024-05-28 02:33:41 +08:00
// 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 ;
}
}
} ) ) ;
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
// 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 ;
}
}
}
2023-07-28 21:20:56 +08:00
2024-02-18 04:22:51 +08:00
return true ;
2023-07-28 21:20:56 +08:00
} catch ( e : any ) {
await editor . flashNotification ( e . message , "error" ) ;
2024-02-18 04:22:51 +08:00
return false ;
2023-07-28 21:20:56 +08:00
}
}
2024-05-28 02:33:41 +08:00
// 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 ] ] ,
} ) ;
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
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 ) ;
}
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
// 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 ;
}
}
2023-08-30 23:25:54 +08:00
}
2024-05-28 02:33:41 +08:00
// Write the new page
const newPageMeta = await space . writePage ( newName , text ) ;
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
// 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 ) ;
}
2023-07-28 21:20:56 +08:00
// 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 ) ;
}
2024-05-28 02:33:41 +08:00
// Update backlinks to this page
const updatedRefences = await updateBacklinks ( oldName , newName ) ;
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
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" ) ;
}
2023-07-28 21:20:56 +08:00
2024-05-28 02:33:41 +08:00
// 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 ) ;
2023-07-28 21:20:56 +08:00
}
2024-05-28 02:33:41 +08:00
// 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" ) ;
2023-07-28 21:20:56 +08:00
}
2024-02-18 04:22:51 +08:00
/ * *
* 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:" , "" ) ;
2023-07-28 21:20:56 +08:00
if ( ! oldPrefix ) {
2024-02-18 04:22:51 +08:00
return false ;
2023-07-28 21:20:56 +08:00
}
2024-02-18 04:22:51 +08:00
const newPrefix = cmdDef . newPrefix ? ?
await editor . prompt ( "New prefix:" , oldPrefix ) ;
2023-07-28 21:20:56 +08:00
if ( ! newPrefix ) {
2024-02-18 04:22:51 +08:00
return false ;
2023-07-28 21:20:56 +08:00
}
2024-05-28 02:33:41 +08:00
const allAttachments = await space . listAttachments ( ) ;
2023-07-28 21:20:56 +08:00
const allPages = await space . listPages ( ) ;
2024-05-28 02:33:41 +08:00
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 )
) ,
2023-07-28 21:20:56 +08:00
) ;
if (
2024-02-18 04:22:51 +08:00
cmdDef . disableConfirmation !== true && ! ( await editor . confirm (
2024-05-28 02:33:41 +08:00
` This will affect ${ allAffectedFiles . length } files. Are you sure? ` ,
2023-07-28 21:20:56 +08:00
) )
) {
2024-02-18 04:22:51 +08:00
return false ;
2023-07-28 21:20:56 +08:00
}
2024-05-28 02:33:41 +08:00
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 ) ;
2023-07-28 21:20:56 +08:00
}
export async function extractToPageCommand() {
2024-02-24 01:27:03 +08:00
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 ) ;
2023-01-05 22:37:08 +08:00
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 ) {
2023-05-24 02:53:53 +08:00
if ( e . message === "Not found" ) {
2023-01-05 22:37:08 +08:00
// 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" ) ;
2024-01-24 18:58:33 +08:00
await editor . navigate ( { page : newName } ) ;
2023-01-05 22:37:08 +08:00
}
2024-05-28 02:33:41 +08:00
/ * *
* 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 ;
}