Merge pull request #3 from IdreesInc/obsidian

Add support for Obsidian
This commit is contained in:
Idrees
2025-11-13 18:41:57 -05:00
committed by GitHub
15 changed files with 3443 additions and 287 deletions

2
.gitignore vendored
View File

@@ -1,3 +1,5 @@
/node_modules
.DS_Store
/dist/birb.bundled.js
obsidian-test.sh
build-cache.json

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?",
"version": "2025.11.3.10",
"version": "__VERSION__",
"homepage_url": "https://idreesinc.com",
"icons": {
"48": "images/icons/transparent/48x48x1.png",

194
build.js
View File

@@ -4,55 +4,104 @@ import { rollup } from 'rollup';
import { readFileSync, writeFileSync, mkdirSync, unlinkSync, cpSync, createWriteStream } from 'fs';
import archiver from 'archiver';
// Path constants
const BUILD_CACHE_PATH = "./build-cache.json";
const SRC_DIR = "./src";
const SPRITES_DIR = "./sprites";
const IMAGES_DIR = "./images";
const FONTS_DIR = "./fonts";
const DIST_DIR = "./dist";
const BROWSER_MANIFEST = "./browser-manifest.json";
const OBSIDIAN_MANIFEST = "./obsidian-manifest.json";
const USERSCRIPT_DIR = DIST_DIR + "/userscript";
const EXTENSION_DIR = DIST_DIR + "/extension";
const OBSIDIAN_DIR = DIST_DIR + "/obsidian";
const STYLESHEET_PATH = SRC_DIR + "/stylesheet.css";
const APPLICATION_ENTRY = SRC_DIR + "/application.js";
const BUNDLED_OUTPUT = DIST_DIR + "/birb.bundled.js";
const BIRB_OUTPUT = DIST_DIR + "/birb.js";
const VERSION_KEY = "__VERSION__";
const STYLESHEET_KEY = "___STYLESHEET___";
const spriteSheets = [
{
key: "__SPRITE_SHEET__",
path: "./sprites/birb.png"
path: SPRITES_DIR + "/birb.png"
},
{
key: "__FEATHER_SPRITE_SHEET__",
path: "./sprites/feather.png"
path: SPRITES_DIR + "/feather.png"
}
];
const STYLESHEET_PATH = "./src/stylesheet.css";
const STYLESHEET_KEY = "___STYLESHEET___";
const BROWSER_MANIFEST = "./browser-manifest.json";
/** @type {Record<string, any>} */
let buildCache = {};
try {
const cacheContent = readFileSync(BUILD_CACHE_PATH, 'utf8');
buildCache = JSON.parse(cacheContent);
} catch (e) {
console.warn("No build cache found, starting fresh");
}
const now = new Date();
const versionDate = `${now.getFullYear()}.${now.getMonth() + 1}.${now.getDate()}`;
// Get current build number from the browser-manifest.json
// Get current build number from the build cache
let buildNumber = 0;
try {
const manifest = JSON.parse(readFileSync(BROWSER_MANIFEST, 'utf8'));
if (manifest.version) {
if (manifest.version.startsWith(versionDate)) {
// Same day, increment build number
const parts = manifest.version.split('.');
if (parts.length === 4) {
buildNumber = parseInt(parts[3], 10) + 1;
}
}
if (buildCache.version && buildCache.version.startsWith(versionDate)) {
// Same day, increment build number
const parts = buildCache.version.split('.');
if (parts.length === 4) {
buildNumber = parseInt(parts[3], 10) + 1;
}
} catch (e) {
console.error("Could not read version from browser manifest");
throw e;
}
// Update manifest.json with new version
const version = `${versionDate}.${buildNumber}`;
try {
const manifest = JSON.parse(readFileSync(BROWSER_MANIFEST, 'utf8'));
manifest.version = version;
writeFileSync(BROWSER_MANIFEST, JSON.stringify(manifest, null, 4), 'utf8');
} catch (e) {
console.error("Could not update version in browser manifest");
throw e;
// Update build cache
buildCache.version = version;
writeFileSync(BUILD_CACHE_PATH, JSON.stringify(buildCache), 'utf8');
// Bundle with rollup
const bundle = await rollup({
input: APPLICATION_ENTRY,
});
await bundle.write({
file: BUNDLED_OUTPUT,
format: 'iife',
});
await bundle.close();
let birbJs = readFileSync(BUNDLED_OUTPUT, 'utf8');
// Delete bundled file
unlinkSync(BUNDLED_OUTPUT);
// Replace version placeholder
birbJs = birbJs.replaceAll(VERSION_KEY, version);
// Compile and insert sprite sheets
for (const spriteSheet of spriteSheets) {
const dataUri = readFileSync(spriteSheet.path, 'base64');
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
}
// Insert stylesheet
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
// Build standard javascript file
writeFileSync(BIRB_OUTPUT, birbJs);
// Build user script
const userScriptHeader =
`// ==UserScript==
`// ==UserScript==
// @name Pocket Bird
// @namespace https://idreesinc.com
// @version ${version}
@@ -67,65 +116,31 @@ const userScriptHeader =
// ==/UserScript==
`;
// Bundle with rollup
const bundle = await rollup({
input: 'src/application.js',
});
await bundle.write({
file: 'dist/birb.bundled.js',
format: 'iife',
});
await bundle.close();
let birbJs = readFileSync('dist/birb.bundled.js', 'utf8');
// Delete bundled file
unlinkSync('./dist/birb.bundled.js');
// Replace version placeholder
birbJs = birbJs.replaceAll('__VERSION__', version);
// Compile and insert sprite sheets
for (const spriteSheet of spriteSheets) {
const dataUri = readFileSync(spriteSheet.path, 'base64');
birbJs = birbJs.replaceAll(spriteSheet.key, `data:image/png;base64,${dataUri}`);
}
// Insert stylesheet
const stylesheetContent = readFileSync(STYLESHEET_PATH, 'utf8');
birbJs = birbJs.replace(STYLESHEET_KEY, stylesheetContent);
// Build standard javascript file
writeFileSync('./dist/birb.js', birbJs);
// Build user script
mkdirSync('./dist/userscript', { recursive: true });
mkdirSync(USERSCRIPT_DIR, { recursive: true });
const userScript = userScriptHeader + birbJs;
writeFileSync('./dist/userscript/birb.user.js', userScript);
writeFileSync(USERSCRIPT_DIR + '/birb.user.js', userScript);
// Build browser extension
mkdirSync('./dist/extension', { recursive: true });
mkdirSync(EXTENSION_DIR, { recursive: true });
// Copy birb.js
writeFileSync('./dist/extension/birb.js', birbJs);
writeFileSync(EXTENSION_DIR + '/birb.js', birbJs);
// Copy manifest.json
const manifestContent = readFileSync(BROWSER_MANIFEST, 'utf8');
writeFileSync('./dist/extension/manifest.json', manifestContent);
let browserManifest = readFileSync(BROWSER_MANIFEST, 'utf8');
browserManifest = browserManifest.replace(VERSION_KEY, version);
writeFileSync(EXTENSION_DIR + '/manifest.json', browserManifest);
// Copy icons folder
mkdirSync('./dist/extension/images/icons', { recursive: true });
cpSync('./images/icons/transparent', './dist/extension/images/icons/transparent', { recursive: true });
mkdirSync(EXTENSION_DIR + '/images/icons', { recursive: true });
cpSync(IMAGES_DIR + '/icons/transparent', EXTENSION_DIR + '/images/icons/transparent', { recursive: true });
// Copy fonts folder
mkdirSync('./dist/extension/fonts', { recursive: true });
cpSync('./fonts', './dist/extension/fonts', { recursive: true });
mkdirSync(EXTENSION_DIR + '/fonts', { recursive: true });
cpSync(FONTS_DIR, EXTENSION_DIR + '/fonts', { recursive: true });
// Compress extension folder into zip
const output = createWriteStream('./dist/extension.zip');
const output = createWriteStream(DIST_DIR + "/extension.zip");
const archive = archiver('zip');
output.on('close', () => {
@@ -137,7 +152,36 @@ archive.on('error', (err) => {
});
archive.pipe(output);
archive.directory('./dist/extension/', false);
archive.directory(EXTENSION_DIR + '/', false);
archive.finalize();
// Build Obsidian plugin
mkdirSync(OBSIDIAN_DIR, { recursive: true });
// Wrap birb.js with plugin boilerplate
const obsidianPlugin = `
const { Plugin, Notice } = require('obsidian');
module.exports = class PocketBird extends Plugin {
onload() {
console.log("Loading Pocket Bird version ${version}...");
const OBSIDIAN_PLUGIN = this;
${birbJs}
console.log("Pocket Bird loaded!");
}
onunload() {
// Remove the birb when the plugin is unloaded
document.getElementById('birb')?.remove();
console.log('Pocket Bird unloaded!');
}
};`
// Create main.js with plugin code
writeFileSync(OBSIDIAN_DIR + '/main.js', obsidianPlugin);
// Copy manifest.json
let obsidianManifest = readFileSync(OBSIDIAN_MANIFEST, 'utf8');
obsidianManifest = obsidianManifest.replace(/"version":\s*".*"/, `"version": "${version}"`);
writeFileSync(OBSIDIAN_DIR + '/manifest.json', obsidianManifest);
console.log(`Build complete: ${version}`);

187
dist/birb.js vendored
View File

@@ -880,6 +880,55 @@
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
@@ -1012,8 +1061,64 @@
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.loadData() ?? {};
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusElementTopMargin() {
return 10;
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
areStickyNotesEnabled() {
return false;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
@@ -1025,7 +1130,22 @@
}
}
error("No applicable context found, defaulting to LocalContext");
return CONTEXTS[0];
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
@@ -1054,46 +1174,6 @@
}
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
* @param {StickyNote} stickyNote
* @returns {boolean} Whether the given sticky note is applicable to the current site/page
*/
function isStickyNoteApplicable(stickyNote) {
const stickyNoteUrl = stickyNote.site;
const currentUrl = window.location.href;
const stickyNoteWebsite = stickyNoteUrl.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== stickyNoteParams.v) {
return false;
}
}
return true;
}
/**
* @param {StickyNote} stickyNote
* @param {() => void} onSave
@@ -1176,8 +1256,9 @@
const existingNotes = document.querySelectorAll(".birb-sticky-note");
existingNotes.forEach(note => note.remove());
// Render all sticky notes
const context = getContext();
for (let stickyNote of stickyNotes) {
if (isStickyNoteApplicable(stickyNote)) {
if (context.isPathApplicable(stickyNote.site)) {
renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
}
}
@@ -1190,7 +1271,7 @@
*/
function createNewStickyNote(stickyNotes, onSave, onDelete) {
const id = Date.now().toString();
const site = window.location.href;
const site = getContext().getPath();
const stickyNote = new StickyNote(id, site, "");
const element = renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`;
@@ -1740,7 +1821,6 @@
// Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100;
const MIN_FOCUS_ELEMENT_TOP = 80;
/** @type {Partial<Settings>} */
let userSettings = {};
@@ -1830,7 +1910,9 @@
const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)),
...(getContext().areStickyNotesEnabled() ? [
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote))
] : []),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => {
frozen = !frozen;
@@ -1867,7 +1949,7 @@
insertModal(`${birdBirb()} Mode`, message);
}),
new Separator(),
new MenuItem("2025.11.3.10", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.3.10"); }, false),
new MenuItem("2025.11.13.27", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.13.27"); }, false),
];
const styleElement = document.createElement("style");
@@ -2062,12 +2144,12 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastUrl = (window.location.href ?? "").split("?")[0];
let lastPath = getContext().getPath().split("?")[0];
setInterval(() => {
const currentUrl = (window.location.href ?? "").split("?")[0];
if (currentUrl !== lastUrl) {
log("URL changed, updating sticky notes");
lastUrl = currentUrl;
const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) {
log("Path changed, updating sticky notes");
lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote);
}
}, URL_CHECK_INTERVAL);
@@ -2494,7 +2576,8 @@
if (frozen) {
return false;
}
const elements = document.querySelectorAll("img, video, .birb-sticky-note");
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => {
const rect = img.getBoundingClientRect();
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= getWindowHeight();

BIN
dist/extension.zip vendored

Binary file not shown.

187
dist/extension/birb.js vendored
View File

@@ -880,6 +880,55 @@
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
@@ -1012,8 +1061,64 @@
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.loadData() ?? {};
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusElementTopMargin() {
return 10;
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
areStickyNotesEnabled() {
return false;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
@@ -1025,7 +1130,22 @@
}
}
error("No applicable context found, defaulting to LocalContext");
return CONTEXTS[0];
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
@@ -1054,46 +1174,6 @@
}
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
* @param {StickyNote} stickyNote
* @returns {boolean} Whether the given sticky note is applicable to the current site/page
*/
function isStickyNoteApplicable(stickyNote) {
const stickyNoteUrl = stickyNote.site;
const currentUrl = window.location.href;
const stickyNoteWebsite = stickyNoteUrl.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== stickyNoteParams.v) {
return false;
}
}
return true;
}
/**
* @param {StickyNote} stickyNote
* @param {() => void} onSave
@@ -1176,8 +1256,9 @@
const existingNotes = document.querySelectorAll(".birb-sticky-note");
existingNotes.forEach(note => note.remove());
// Render all sticky notes
const context = getContext();
for (let stickyNote of stickyNotes) {
if (isStickyNoteApplicable(stickyNote)) {
if (context.isPathApplicable(stickyNote.site)) {
renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
}
}
@@ -1190,7 +1271,7 @@
*/
function createNewStickyNote(stickyNotes, onSave, onDelete) {
const id = Date.now().toString();
const site = window.location.href;
const site = getContext().getPath();
const stickyNote = new StickyNote(id, site, "");
const element = renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`;
@@ -1740,7 +1821,6 @@
// Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100;
const MIN_FOCUS_ELEMENT_TOP = 80;
/** @type {Partial<Settings>} */
let userSettings = {};
@@ -1830,7 +1910,9 @@
const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)),
...(getContext().areStickyNotesEnabled() ? [
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote))
] : []),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => {
frozen = !frozen;
@@ -1867,7 +1949,7 @@
insertModal(`${birdBirb()} Mode`, message);
}),
new Separator(),
new MenuItem("2025.11.3.10", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.3.10"); }, false),
new MenuItem("2025.11.13.27", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.13.27"); }, false),
];
const styleElement = document.createElement("style");
@@ -2062,12 +2144,12 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastUrl = (window.location.href ?? "").split("?")[0];
let lastPath = getContext().getPath().split("?")[0];
setInterval(() => {
const currentUrl = (window.location.href ?? "").split("?")[0];
if (currentUrl !== lastUrl) {
log("URL changed, updating sticky notes");
lastUrl = currentUrl;
const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) {
log("Path changed, updating sticky notes");
lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote);
}
}, URL_CHECK_INTERVAL);
@@ -2494,7 +2576,8 @@
if (frozen) {
return false;
}
const elements = document.querySelectorAll("img, video, .birb-sticky-note");
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => {
const rect = img.getBoundingClientRect();
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= getWindowHeight();

View File

@@ -2,7 +2,7 @@
"manifest_version": 3,
"name": "Pocket Bird",
"description": "It's a pet bird in your browser, what more could you want?",
"version": "2025.11.3.10",
"version": "2025.11.13.27",
"homepage_url": "https://idreesinc.com",
"icons": {
"48": "images/icons/transparent/48x48x1.png",

2758
dist/obsidian/main.js vendored Normal file

File diff suppressed because it is too large Load Diff

10
dist/obsidian/manifest.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"id": "pocket-bird",
"name": "Pocket Bird",
"version": "2025.11.13.27",
"minAppVersion": "0.15.0",
"description": "It's a pet bird in your Obsidian, what more could you want?",
"author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com",
"isDesktopOnly": false
}

View File

@@ -1,7 +1,7 @@
// ==UserScript==
// @name Pocket Bird
// @namespace https://idreesinc.com
// @version 2025.11.3.10
// @version 2025.11.13.27
// @description It's a bird that hops around your web browser, the future is here
// @author Idrees
// @downloadURL https://github.com/IdreesInc/Pocket-Bird/raw/refs/heads/main/dist/userscript/birb.user.js
@@ -894,6 +894,55 @@
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
class LocalContext extends Context {
@@ -1026,8 +1075,64 @@
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.loadData() ?? {};
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusElementTopMargin() {
return 10;
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
areStickyNotesEnabled() {
return false;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
@@ -1039,7 +1144,22 @@
}
}
error("No applicable context found, defaulting to LocalContext");
return CONTEXTS[0];
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
@@ -1068,46 +1188,6 @@
}
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
* @param {StickyNote} stickyNote
* @returns {boolean} Whether the given sticky note is applicable to the current site/page
*/
function isStickyNoteApplicable(stickyNote) {
const stickyNoteUrl = stickyNote.site;
const currentUrl = window.location.href;
const stickyNoteWebsite = stickyNoteUrl.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== stickyNoteParams.v) {
return false;
}
}
return true;
}
/**
* @param {StickyNote} stickyNote
* @param {() => void} onSave
@@ -1190,8 +1270,9 @@
const existingNotes = document.querySelectorAll(".birb-sticky-note");
existingNotes.forEach(note => note.remove());
// Render all sticky notes
const context = getContext();
for (let stickyNote of stickyNotes) {
if (isStickyNoteApplicable(stickyNote)) {
if (context.isPathApplicable(stickyNote.site)) {
renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
}
}
@@ -1204,7 +1285,7 @@
*/
function createNewStickyNote(stickyNotes, onSave, onDelete) {
const id = Date.now().toString();
const site = window.location.href;
const site = getContext().getPath();
const stickyNote = new StickyNote(id, site, "");
const element = renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`;
@@ -1754,7 +1835,6 @@
// Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100;
const MIN_FOCUS_ELEMENT_TOP = 80;
/** @type {Partial<Settings>} */
let userSettings = {};
@@ -1844,7 +1924,9 @@
const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)),
...(getContext().areStickyNotesEnabled() ? [
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote))
] : []),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => {
frozen = !frozen;
@@ -1881,7 +1963,7 @@
insertModal(`${birdBirb()} Mode`, message);
}),
new Separator(),
new MenuItem("2025.11.3.10", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.3.10"); }, false),
new MenuItem("2025.11.13.27", () => { alert("Thank you for using Pocket Bird! You are on version: 2025.11.13.27"); }, false),
];
const styleElement = document.createElement("style");
@@ -2076,12 +2158,12 @@
drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastUrl = (window.location.href ?? "").split("?")[0];
let lastPath = getContext().getPath().split("?")[0];
setInterval(() => {
const currentUrl = (window.location.href ?? "").split("?")[0];
if (currentUrl !== lastUrl) {
log("URL changed, updating sticky notes");
lastUrl = currentUrl;
const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) {
log("Path changed, updating sticky notes");
lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote);
}
}, URL_CHECK_INTERVAL);
@@ -2508,7 +2590,8 @@
if (frozen) {
return false;
}
const elements = document.querySelectorAll("img, video, .birb-sticky-note");
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => {
const rect = img.getBoundingClientRect();
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= getWindowHeight();

10
obsidian-manifest.json Normal file
View File

@@ -0,0 +1,10 @@
{
"id": "pocket-bird",
"name": "Pocket Bird",
"version": "__VERSION__",
"minAppVersion": "0.15.0",
"description": "It's a pet bird in your Obsidian, what more could you want?",
"author": "Idrees Hassan",
"authorUrl": "https://idreesinc.com",
"isDesktopOnly": false
}

View File

@@ -7,7 +7,7 @@
"type": "module",
"scripts": {
"build": "node build.js",
"dev": "nodemon --watch src --watch stylesheet.css --watch build.js --exec \"npm run build\""
"dev": "nodemon --watch src --watch package.json --watch stylesheet.css --watch build.js --watch obsidian-manifest.json --watch browser-manifest.json --exec \"npm run build\""
},
"devDependencies": {
"archiver": "^7.0.1",

View File

@@ -104,7 +104,6 @@ const PET_FEATHER_BOOST = 2;
// Focus element constraints
const MIN_FOCUS_ELEMENT_WIDTH = 100;
const MIN_FOCUS_ELEMENT_TOP = 80;
/** @type {Partial<Settings>} */
let userSettings = {};
@@ -194,7 +193,9 @@ Promise.all([
const menuItems = [
new MenuItem(`Pet ${birdBirb()}`, pet),
new MenuItem("Field Guide", insertFieldGuide),
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote)),
...(getContext().areStickyNotesEnabled() ? [
new MenuItem("Sticky Note", () => createNewStickyNote(stickyNotes, save, deleteStickyNote))
] : []),
new MenuItem(`Hide ${birdBirb()}`, () => birb.setVisible(false)),
new DebugMenuItem("Freeze/Unfreeze", () => {
frozen = !frozen;
@@ -426,12 +427,12 @@ Promise.all([
drawStickyNotes(stickyNotes, save, deleteStickyNote);
let lastUrl = (window.location.href ?? "").split("?")[0];
let lastPath = getContext().getPath().split("?")[0];
setInterval(() => {
const currentUrl = (window.location.href ?? "").split("?")[0];
if (currentUrl !== lastUrl) {
log("URL changed, updating sticky notes");
lastUrl = currentUrl;
const currentPath = getContext().getPath().split("?")[0];
if (currentPath !== lastPath) {
log("Path changed, updating sticky notes");
lastPath = currentPath;
drawStickyNotes(stickyNotes, save, deleteStickyNote);
}
}, URL_CHECK_INTERVAL);
@@ -862,7 +863,8 @@ Promise.all([
if (frozen) {
return false;
}
const elements = document.querySelectorAll("img, video, .birb-sticky-note");
const MIN_FOCUS_ELEMENT_TOP = getContext().getFocusElementTopMargin();
const elements = document.querySelectorAll(getContext().getFocusableElements().join(", "));
const inWindow = Array.from(elements).filter((img) => {
const rect = img.getBoundingClientRect();
return rect.left >= 0 && rect.top >= MIN_FOCUS_ELEMENT_TOP && rect.right <= window.innerWidth && rect.top <= getWindowHeight();

View File

@@ -1,4 +1,3 @@
import { debug, log, error } from "./shared.js";
const SAVE_KEY = "birbSaveData";
@@ -42,6 +41,55 @@ export class Context {
resetSaveData() {
throw new Error("Method not implemented");
}
/**
* @returns {string[]} A list of CSS selectors for focusable elements
*/
getFocusableElements() {
return ["img", "video", ".birb-sticky-note"];
}
getFocusElementTopMargin() {
return 80;
}
/**
* @returns {string} The current path of the active page in this context
*/
getPath() {
// Default to website URL
return window.location.href;
}
/**
* Checks if a path is applicable given the context
* @param {string} path Can be a site URL or another context-specific path
* @returns {boolean} Whether the path matches the current context state
*/
isPathApplicable(path) {
// Default to website URL matching
const currentUrl = window.location.href;
const stickyNoteWebsite = path.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const pathParams = parseUrlParams(path);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== pathParams.v) {
return false;
}
}
return true;
}
areStickyNotesEnabled() {
return true;
}
}
export class LocalContext extends Context {
@@ -174,8 +222,64 @@ class BrowserExtensionContext extends Context {
}
}
class ObsidianContext extends Context {
/**
* @override
* @returns {boolean}
*/
isContextActive() {
// @ts-expect-error
return typeof app !== "undefined" && typeof app.vault !== "undefined";
}
/**
* @override
* @returns {Promise<BirbSaveData|{}>}
*/
async getSaveData() {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.loadData() ?? {};
}
/**
* @override
* @param {BirbSaveData|{}} saveData
*/
async putSaveData(saveData) {
// @ts-expect-error
return await OBSIDIAN_PLUGIN.saveData(saveData);
}
/** @override */
resetSaveData() {
this.putSaveData({});
}
/** @override */
getFocusElementTopMargin() {
return 10;
}
/** @override */
getFocusableElements() {
const elements = [
".workspace-leaf",
".cm-callout",
".HyperMD-codeblock-begin"
];
return super.getFocusableElements().concat(elements);
}
/** @override */
areStickyNotesEnabled() {
return false;
}
}
const CONTEXTS = [
new UserScriptContext(),
new ObsidianContext(),
new BrowserExtensionContext(),
new LocalContext()
];
@@ -187,5 +291,20 @@ export function getContext() {
}
}
error("No applicable context found, defaulting to LocalContext");
return CONTEXTS[0];
return new LocalContext();
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}

View File

@@ -3,6 +3,7 @@ import {
makeDraggable,
makeClosable
} from './shared.js';
import { getContext } from './context.js';
/**
* @typedef {Object} SavedStickyNote
@@ -30,46 +31,6 @@ export class StickyNote {
}
}
/**
* Parse URL parameters into a key-value map
* @param {string} url
* @returns {Record<string, string>}
*/
export function parseUrlParams(url) {
const queryString = url.split("?")[1];
if (!queryString) return {};
return queryString.split("&").reduce((params, param) => {
const [key, value] = param.split("=");
return { ...params, [key]: value };
}, {});
}
/**
* @param {StickyNote} stickyNote
* @returns {boolean} Whether the given sticky note is applicable to the current site/page
*/
export function isStickyNoteApplicable(stickyNote) {
const stickyNoteUrl = stickyNote.site;
const currentUrl = window.location.href;
const stickyNoteWebsite = stickyNoteUrl.split("?")[0];
const currentWebsite = currentUrl.split("?")[0];
if (stickyNoteWebsite !== currentWebsite) {
return false;
}
const stickyNoteParams = parseUrlParams(stickyNoteUrl);
const currentParams = parseUrlParams(currentUrl);
if (window.location.hostname === "www.youtube.com") {
if (currentParams.v !== undefined && currentParams.v !== stickyNoteParams.v) {
return false;
}
}
return true;
}
/**
* @param {StickyNote} stickyNote
* @param {() => void} onSave
@@ -152,8 +113,9 @@ export function drawStickyNotes(stickyNotes, onSave, onDelete) {
const existingNotes = document.querySelectorAll(".birb-sticky-note");
existingNotes.forEach(note => note.remove());
// Render all sticky notes
const context = getContext();
for (let stickyNote of stickyNotes) {
if (isStickyNoteApplicable(stickyNote)) {
if (context.isPathApplicable(stickyNote.site)) {
renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
}
}
@@ -166,7 +128,7 @@ export function drawStickyNotes(stickyNotes, onSave, onDelete) {
*/
export function createNewStickyNote(stickyNotes, onSave, onDelete) {
const id = Date.now().toString();
const site = window.location.href;
const site = getContext().getPath();
const stickyNote = new StickyNote(id, site, "");
const element = renderStickyNote(stickyNote, onSave, () => onDelete(stickyNote));
element.style.left = `${window.innerWidth / 2 - element.offsetWidth / 2}px`;