-
-
Save jsloat/142aef35fd8b6fd8e6d1fbb850653558 to your computer and use it in GitHub Desktop.
// 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 | |
// | |
// 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. | |
* If you use this script on different devices, you can use Device.isPad(), for example, to choose the right one. | |
* | |
* 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 = /\[\[(.+?)\]\]/g; | |
/** @param {string} noteBody */ | |
const getNoteLinks = noteBody => | |
uniqueArray( | |
noteBody | |
.split("\n") | |
.flatMap(line => | |
[...line.matchAll(noteLinkInNoteRegex)].map(match => match[1]) | |
) | |
.filter(Boolean) | |
); | |
/** Do string arrays have same values, in any order? */ | |
const stringArraysHaveSameValues = (arr1, arr2) => { | |
if (arr1.length !== arr2.length) return false; | |
return arr1.every(arr1Val => arr2.some(arr2Val => arr1Val === arr2Val)); | |
}; | |
/** | |
* Given array of strings, return array of lines removing all empty lines | |
* at beginning and end of lines. | |
*/ | |
const trimLines = lines => { | |
const { firstContentLine, lastContentLine } = lines.reduce( | |
(acc, line, i) => { | |
const lineHasContent = Boolean(line.trim().length); | |
if (acc.firstContentLine === -1 && lineHasContent) | |
acc.firstContentLine = i; | |
if (lineHasContent) acc.lastContentLine = i; | |
return acc; | |
}, | |
{ firstContentLine: -1, lastContentLine: -1 } | |
); | |
return lastContentLine === -1 | |
? [] | |
: lines.slice(firstContentLine, lastContentLine + 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, | |
open_note: "no", | |
}); | |
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", | |
open_note: "no", | |
}); | |
return await callback.open(); | |
}; | |
// | |
// NOTE PARSING | |
// | |
const METADATA_DIVIDER = "---"; | |
const METADATA_TITLE = "::*METADATA*::"; | |
const METADATA_LINE_PREFIX = "\t- [["; | |
/** With ability to link to sections in notes, notes w/ "/" in title must get special handling */ | |
const cleanNoteLink = link => { | |
const hasSectionLink = /[^\\](\/.+$)/.test(link); | |
const withoutSectionLink = (() => { | |
if (!hasSectionLink) return link; | |
const splitBySlashes = link.split("/"); | |
splitBySlashes.pop(); | |
return splitBySlashes.join("/"); | |
})(); | |
return withoutSectionLink.replace(/\\\//g, "/"); | |
}; | |
const getMetadataFromNote = note => { | |
const defaultReturn = { | |
noteWithoutMetadata: note, | |
currentBacklinks: [], | |
}; | |
const lines = note.split("\n"); | |
const metadataTitleLineIndex = lines.indexOf(METADATA_TITLE); | |
if (metadataTitleLineIndex === -1) return defaultReturn; | |
const closingDividerIndex = lines.findIndex( | |
(line, i) => i > metadataTitleLineIndex && line === METADATA_DIVIDER | |
); | |
if (closingDividerIndex === -1) return defaultReturn; | |
const metadataLines = lines.splice( | |
metadataTitleLineIndex - 1, | |
closingDividerIndex - (metadataTitleLineIndex - 1) + 1 | |
); | |
const currentBacklinks = metadataLines | |
.filter(line => line.startsWith(METADATA_LINE_PREFIX)) | |
.map(line => line.replace(METADATA_LINE_PREFIX, "").replace("]]", "")) | |
.map(cleanNoteLink); | |
return { noteWithoutMetadata: lines.join("\n"), currentBacklinks }; | |
}; | |
const getForwardLinks = (noteWithoutMetadata, noteTitle) => | |
getNoteLinks(noteWithoutMetadata) | |
.map(cleanNoteLink) | |
// This can happen if linking to a subsection within a note | |
.filter(forwardLink => forwardLink !== noteTitle); | |
/** | |
* To start, get full notebody for all links that may have note links in them. | |
* False positives are removed, leaving a cache of notes that link to other notes. | |
* False positive note titles are logged in console; correcting this (they contain "[[") | |
* can speed up the script, especially on iPhone. | |
*/ | |
const populateCacheWithNotesWithLinks = async () => { | |
const allNotesThatMayHaveNoteLinks = await getBearSearchResults("[["); | |
return ( | |
await Promise.all( | |
allNotesThatMayHaveNoteLinks.map(async ({ identifier, title }) => { | |
const { note } = await getFullBearNote(identifier); | |
const { noteWithoutMetadata, currentBacklinks } = getMetadataFromNote( | |
note | |
); | |
const forwardLinksInBody = getForwardLinks(noteWithoutMetadata, title); | |
const isFalsePositive = !( | |
currentBacklinks.length || forwardLinksInBody.length | |
); | |
if (isFalsePositive) { | |
console.log( | |
`Note "${title}" matches search "[[", but contains no note links.` | |
); | |
return null; | |
} | |
return { | |
identifier, | |
title, | |
noteWithoutMetadata, | |
forwardLinksInBody, | |
currentBacklinks, | |
}; | |
}) | |
) | |
).filter(Boolean); | |
}; | |
/** | |
* Initial cache load only pulls from notes that contain "[[", | |
* so some target notes without links in them may be missing. | |
*/ | |
const completeCacheWithNotesWithoutLinks = async cache => { | |
const allNotes = await getBearSearchResults(NOTES_SEARCH_TERM); | |
const allLinkedNoteTitles = uniqueArray( | |
cache.flatMap(({ currentBacklinks, forwardLinksInBody }) => [ | |
...currentBacklinks, | |
...forwardLinksInBody, | |
]) | |
); | |
const linkedNotesNotInCache = allLinkedNoteTitles | |
.filter( | |
noteTitle => !cache.some(cachedNote => cachedNote.title === noteTitle) | |
) | |
.map(linkedNoteTitle => | |
allNotes.find(note => note.title === linkedNoteTitle) | |
) | |
.filter(Boolean); | |
await Promise.all( | |
linkedNotesNotInCache.map(async ({ identifier, title }) => { | |
const { note } = await getFullBearNote(identifier); | |
cache.push({ | |
identifier, | |
title, | |
noteWithoutMetadata: note, | |
forwardLinksInBody: [], | |
currentBacklinks: [], | |
}); | |
}) | |
); | |
}; | |
/** Re-organize cache data into pairs of link target note ID & array of source titles linking to it. */ | |
const getBacklinkIndex = cache => { | |
const allForwardLinks = uniqueArray( | |
cache.flatMap(({ forwardLinksInBody }) => forwardLinksInBody) | |
); | |
return allForwardLinks.map(targetNoteTitle => ({ | |
targetNoteTitle, | |
linkSourceTitles: cache | |
.filter(({ forwardLinksInBody }) => | |
forwardLinksInBody.includes(targetNoteTitle) | |
) | |
.map(({ title }) => title), | |
})); | |
}; | |
const getMetadataLines = backlinkTitles => | |
backlinkTitles.length | |
? [ | |
METADATA_DIVIDER, | |
METADATA_TITLE, | |
"### Backlinks", | |
backlinkTitles | |
// Must escape the slash per Bear linking mechanics | |
.map(title => `\t- [[${title.replace(/\//g, "\\/")}]]`) | |
.join("\n"), | |
METADATA_DIVIDER, | |
] | |
: null; | |
/** | |
* If the cached note has backlinks, they are different from those in backlinkIndex, | |
* the backlinks should be updated. | |
*/ | |
const hasOutdatedBacklinks = ({ title, currentBacklinks }, backlinkIndex) => | |
backlinkIndex.some( | |
({ targetNoteTitle, linkSourceTitles }) => | |
targetNoteTitle === title && | |
!stringArraysHaveSameValues(linkSourceTitles, currentBacklinks) | |
); | |
/** If the cached note has backlinks, but no entry in backlinkIndex, the backlinks are no longer valid. */ | |
const areAllBacklinksMissing = ({ title, currentBacklinks }, backlinkIndex) => | |
currentBacklinks.length && | |
!backlinkIndex.some(({ targetNoteTitle }) => title === targetNoteTitle); | |
/** Returns cached note + backlink metadata for notes in cache that need to be updated. */ | |
const getNotesChangesToPush = (cache, backlinkIndex) => | |
cache | |
.filter( | |
cachedNote => | |
hasOutdatedBacklinks(cachedNote, backlinkIndex) || | |
areAllBacklinksMissing(cachedNote, backlinkIndex) | |
) | |
.map(({ title, identifier, noteWithoutMetadata }) => { | |
const backlinkIndexData = backlinkIndex.find( | |
({ targetNoteTitle }) => title === targetNoteTitle | |
); | |
if (!backlinkIndexData) return null; | |
const { linkSourceTitles } = backlinkIndexData; | |
return { identifier, noteWithoutMetadata, linkSourceTitles }; | |
}) | |
.filter(Boolean); | |
const createBacklinks = async () => { | |
const cachedNotes = await populateCacheWithNotesWithLinks(); | |
await completeCacheWithNotesWithoutLinks(cachedNotes); | |
const backlinkIndex = getBacklinkIndex(cachedNotes); | |
const numResults = ( | |
await Promise.all( | |
getNotesChangesToPush(cachedNotes, backlinkIndex).map( | |
async ({ linkSourceTitles, identifier, noteWithoutMetadata }) => | |
await replaceBearNoteBody( | |
identifier, | |
[ | |
...trimLines(noteWithoutMetadata.split("\n")), | |
...(getMetadataLines(linkSourceTitles) || []), | |
].join("\n") | |
) | |
) | |
) | |
).length; | |
console.log(`Backlink parsing done -- updated ${numResults} notes.`); | |
}; | |
await createBacklinks(); |
Updated version posted -- I refactored this for my devices a couple months ago so the code will look different, but the basic functionality is the same and it's backwards compatible if you've already been using it. But per usual, probably a good idea to backup your Bear database before trying :)
Only thing that was removed was the "blacklist" notes (array of note titles to exclude as link sources). I wasn't using this so I removed, but if anyone misses it I can add it back in.
The new version supports wiki links/note headers as @xlZeroAccesslx mentions above, plus some smaller tweaks that aren't really noticeable. I think this version uses less device memory so if anyone was experiencing slowness/crashes before, this may fix that.
Great work man!
I would love if I could see some context in the backlinks as well, pulling in the paragraph were the link is mentioned.
Here is my backlinks from a page like the script is working now:
epigenetics
::METADATA::
Backlinks
- [[S- 2020 - Who We Are - YouTube - Documentary]]
Here is how I ideally want it to look to get more context:
epigenetics
::METADATA::
Backlinks
- [[S- 2020 - Who We Are - YouTube - Documentary]]
- [[epigenetics]] - Control above the genes.
@Torgithub thanks! This is a nice idea, definitely possible. I may give this a go at some point, but probably not in the near-term.
@xlZeroAccesslx nice catch 👍 I have actually updated my own code to support this but didn't update here. I think your solution would probably work but I will just update the whole file with my latest version, will be up within a half hour