-
-
Save xlZeroAccesslx/8257f34c40ef3922916e8e10d12d4e28 to your computer and use it in GitHub Desktop.
Generate backlinks for Bear notes in Scriptable on iOS
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// Variables used by Scriptable. | |
// These must be at the very top of the file. Do not edit. | |
// icon-color: blue; icon-glyph: link; | |
/** | |
* GENERATE BEAR BACKLINKS | |
* | |
* This script will find and add backlinks in Bear. | |
* | |
* !!Please backup your notes before running!! https://bear.app/faq/Backup%20&%20Restore/ | |
* I haven't had any issues with running this script, but have only tested it | |
* with my notes. I would strongly suggest that you back up your notes so you | |
* can restore them if you don't like the outcome. | |
* | |
* INSTRUCTIONS | |
* 1. Edit the Settings below this comment block | |
* 2. Turn on "Reduce motion" setting: https://support.apple.com/en-gb/HT202655 | |
* - This isn't mandatory, but will speed up the execution of the script. Because | |
* we have to make a roundtrip between Scriptable and Bear for each note evaluated, | |
* this can take a very long time to run (I've had it run for ~30 minutes with 770 notes). | |
* Turning reduce motion on significantly reduces the amount of time each roundtrip takes. | |
* - [UPDATE 2020-11-11 -- the script seems to be broken in Split View, probably due to some OS or app changes] | |
* If you run this on an iPad with split view support, having Scriptble and Bear open | |
* next to each other makes this run exponentially faster, as there is no app switching. | |
* 3. Run script | |
* - NB! You are effectively locked out of your device while this is running. You can quit | |
* the apps if you're fast enough, but it is challenging. Make sure you won't need the device | |
* while this is running. | |
*/ | |
// | |
// SETTINGS | |
// | |
// Do not include these notes as backlink sources. | |
const PARTIAL_NOTE_TITLE_BLACKLIST = [ | |
'Title of some note whose outgoing links should be ignored', | |
]; | |
// The results of this search will be the body of notes used to find backlinks. | |
// The default here shows all notes that aren't locked (which for me is all notes). | |
// The search term can be tested in Bear to see which notes will be included. | |
// https://bear.app/faq/Advanced%20search%20options%20in%20Bear/ | |
const NOTES_SEARCH_TERM = '-@locked'; | |
/** | |
* Place token for your device between quotes below. Note that different devices have different tokens. | |
* From Bear documentation (https://bear.app/faq/X-callback-url%20Scheme%20documentation/): | |
* | |
* In order to extend their functionalties, some of the API calls allow an app generated token to be | |
* passed along with the other parameters. Please mind a Token generated on iOS is not valid for MacOS and vice-versa. | |
* | |
* On MacOS, select Help → API Token → Copy Token and will be available in your pasteboard. | |
* | |
* On iOS go to the preferences → General, locate the API Token section and tap the cell below | |
* to generate the token or copy it in your pasteboard. | |
*/ | |
const BEAR_TOKEN = ''; | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// | |
// HELPERS | |
// | |
const uniqueArray = (...arrays) => [...new Set(arrays.flatMap(i => i))]; | |
const noteLinkInNoteRegex = /\[\[([\w\d\s\(\)\:\-\_]+)(?:\/.+|)\]\]/g; | |
/** @param {string} noteBody */ | |
const getNoteLinks = noteBody => { | |
const noteLines = noteBody.split('\n'); | |
return uniqueArray( | |
noteLines.flatMap(line => | |
[...line.matchAll(noteLinkInNoteRegex)].map(match => match[1]) | |
) | |
); | |
}; | |
// | |
// BEAR XCALLBACK FUNCTIONS | |
// | |
const BASE_URL = 'bear://x-callback-url'; | |
const getBearCallbackObject = (endpoint, params) => { | |
const callbackObject = new CallbackURL(`${BASE_URL}/${endpoint}`); | |
Object.entries(params).forEach(([key, val]) => | |
callbackObject.addParameter(key, val) | |
); | |
callbackObject.addParameter('token', BEAR_TOKEN); | |
return callbackObject; | |
}; | |
const getFullBearNote = async noteId => { | |
const callback = getBearCallbackObject('open-note', { id: noteId }); | |
return await callback.open(); | |
}; | |
const getBearSearchResults = async term => { | |
const callback = getBearCallbackObject('search', { term }); | |
const resultsRaw = await callback.open(); | |
return JSON.parse(resultsRaw.notes); | |
}; | |
const replaceBearNoteBody = async (noteId, newNoteBody) => { | |
const callback = getBearCallbackObject('add-text', { | |
id: noteId, | |
text: newNoteBody, | |
mode: 'replace_all', | |
}); | |
return await callback.open(); | |
}; | |
const appendTextToBearNote = async (noteId, appendedText) => { | |
const callback = getBearCallbackObject('add-text', { | |
id: noteId, | |
text: appendedText, | |
mode: 'append', | |
}); | |
return await callback.open(); | |
}; | |
// | |
// NOTE PARSING | |
// | |
const METADATA_DIVIDER = '---'; | |
const METADATA_TITLE = '::*METADATA*::'; | |
/** Given backlink titles for note, generate its metadata text block */ | |
const getMetadataLines = backlinkTitles => { | |
const hasBacklinks = Boolean(backlinkTitles.length); | |
if (!hasBacklinks) return null; | |
return [ | |
' ', | |
METADATA_DIVIDER, | |
METADATA_TITLE, | |
'### Backlinks', | |
backlinkTitles.map(title => `\t- [[${title}]]`).join('\n'), | |
METADATA_DIVIDER, | |
].filter(Boolean); | |
}; | |
/** Removes the metadata block, if it exists, containing backlinks */ | |
const stripNoteBodyOfMetadata = note => { | |
const lines = note.split('\n'); | |
const metadataTitleLineIndex = lines.indexOf(METADATA_TITLE); | |
if (metadataTitleLineIndex === -1) return { note, hasMetadata: false }; | |
const closingDividerIndex = lines.findIndex( | |
(line, i) => i > metadataTitleLineIndex && line === METADATA_DIVIDER | |
); | |
if (closingDividerIndex === -1) return { note, hasMetadata: false }; | |
lines.splice( | |
metadataTitleLineIndex - 1, | |
closingDividerIndex - (metadataTitleLineIndex - 1) + 1 | |
); | |
const lastLineIsEmpty = lines[lines.length - 1].trim() === ''; | |
if (lastLineIsEmpty) lines.splice(lines.length - 1); | |
return { note: lines.join('\n'), hasMetadata: true }; | |
}; | |
/** Get unique list of note titles that a given note links to in its body. Ignore note titles on the blacklist */ | |
const getLinkedNoteTitlesInNote = note => { | |
return getNoteLinks(note).filter( | |
linkTitle => | |
!PARTIAL_NOTE_TITLE_BLACKLIST.some(blacklistTitle => | |
linkTitle.includes(blacklistTitle) | |
) | |
); | |
}; | |
// | |
// MAIN | |
// | |
/** | |
* Extract links from full note body, then remove existing metadata text blocks. | |
* Removing the metadata blocks is important, as it allows us to prevent storing full note body in memory. | |
*/ | |
const parseFullNoteAndRemoveMetadataBlock = async ({ noteId, noteTitle }) => { | |
const { note } = await getFullBearNote(noteId); | |
const { | |
note: noteWithoutMetadataText, | |
hasMetadata, | |
} = stripNoteBodyOfMetadata(note); | |
const forwardLinkTitles = getLinkedNoteTitlesInNote(noteWithoutMetadataText); | |
if (hasMetadata) await replaceBearNoteBody(noteId, noteWithoutMetadataText); | |
return { noteId, noteTitle, forwardLinkTitles }; | |
}; | |
/** | |
* Create object indexed by link target title (where backlinks will be added) | |
* w/ array of note titles that link to it | |
*/ | |
const getLinkTargetTitleIndex = allNoteMetadata => | |
allNoteMetadata.reduce((acc, { forwardLinkTitles, noteTitle }) => { | |
forwardLinkTitles.forEach(linkedTitle => | |
acc[linkedTitle] | |
? acc[linkedTitle].push(noteTitle) | |
: (acc[linkedTitle] = [noteTitle]) | |
); | |
return acc; | |
}, {}); | |
const getAllNotesMetadataAndClearMetadataText = async () => { | |
const relevantNotes = await getBearSearchResults(NOTES_SEARCH_TERM); | |
// Get note metadata and clear existing metadata blocks | |
const allNoteMetadata = await Promise.all( | |
relevantNotes.map(({ identifier, title }) => { | |
return parseFullNoteAndRemoveMetadataBlock({ | |
noteId: identifier, | |
noteTitle: title, | |
}); | |
}) | |
); | |
const linkTargetTitleIndex = getLinkTargetTitleIndex(allNoteMetadata); | |
return allNoteMetadata.map(({ noteId, noteTitle }) => { | |
// Array of note titles linking to this one | |
const linkSourceTitles = linkTargetTitleIndex[noteTitle] || []; | |
return { noteId, linkSourceTitles }; | |
}); | |
}; | |
const createBacklinks = async () => { | |
const metadata = await getAllNotesMetadataAndClearMetadataText(); | |
const metadataForNotesThatWillUpdate = metadata.filter( | |
({ linkSourceTitles }) => linkSourceTitles.length | |
); | |
await Promise.all( | |
metadataForNotesThatWillUpdate.map(async ({ noteId, linkSourceTitles }) => { | |
const metadataText = getMetadataLines(linkSourceTitles); | |
if (!metadataText) return; | |
await appendTextToBearNote(noteId, `\n\n${metadataText.join('\n')}`); | |
}) | |
); | |
console.log('Done'); | |
}; | |
await createBacklinks(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment