├── .gitignore ├── .prettierrc ├── src ├── index.tests.ts ├── lib │ ├── utils │ │ ├── string.ts │ │ ├── regexp.ts │ │ ├── merge-content.ts │ │ ├── fit-textarea-to-content.ts │ │ ├── tags.ts │ │ ├── lazy-apply.ts │ │ ├── user-options.ts │ │ ├── base64.ts │ │ ├── markdown.ts │ │ └── test │ │ │ ├── lazy-apply.test.ts │ │ │ └── parse-entry.test.ts │ ├── sites │ │ ├── sites.ts │ │ └── domains │ │ │ ├── youtube.ts │ │ │ ├── default.ts │ │ │ └── github.ts │ └── github │ │ └── rest-api.ts ├── popup.ts ├── popup │ ├── model.ts │ ├── controller.ts │ └── view.ts ├── content-script.ts └── options.ts ├── scripts ├── jsconfig.json ├── pack.js └── copy-assets.js ├── public ├── assets │ ├── osmosmemo-icon-128.png │ ├── osmosmemo-icon-16.png │ ├── osmosmemo-icon-24.png │ ├── osmosmemo-icon-32.png │ ├── osmosmemo-icon-48.png │ └── osmosmemo-icon-96.png ├── manifest.json ├── popup.html ├── options.html └── styles.css ├── .vscode └── settings.json ├── run-test.ts ├── tsconfig.json ├── LICENSE ├── package.json ├── CHANGELOG.md ├── README.md └── docs └── media └── osmosmemo-square-badge.svg /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | 2 | { 3 | "printWidth": 120 4 | } -------------------------------------------------------------------------------- /src/index.tests.ts: -------------------------------------------------------------------------------- 1 | import "./lib/utils/test/lazy-apply.test"; 2 | import "./lib/utils/test/parse-entry.test"; 3 | -------------------------------------------------------------------------------- /scripts/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "checkJs": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-128.png -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-16.png -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-24.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-24.png -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-32.png -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-48.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-48.png -------------------------------------------------------------------------------- /public/assets/osmosmemo-icon-96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/osmoscraft/osmosmemo/HEAD/public/assets/osmosmemo-icon-96.png -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.formatOnSave": true, 3 | "prettier.printWidth": 160, 4 | "prettier.singleQuote": true, 5 | "prettier.trailingComma": "es5" 6 | } -------------------------------------------------------------------------------- /src/lib/utils/string.ts: -------------------------------------------------------------------------------- 1 | export function truncateString(str: string, maxLength: number) { 2 | return str.length > maxLength ? str.slice(0, maxLength) + "…" : str; 3 | } 4 | -------------------------------------------------------------------------------- /run-test.ts: -------------------------------------------------------------------------------- 1 | import { getTests, runTests } from "@osmoscraft/typescript-testing-library"; 2 | 3 | async function run() { 4 | const tests = await getTests("src", /\.test\.ts$/); 5 | runTests(tests); 6 | } 7 | 8 | run(); 9 | -------------------------------------------------------------------------------- /src/lib/utils/regexp.ts: -------------------------------------------------------------------------------- 1 | // ref: https://stackoverflow.com/questions/3446170/escape-string-for-use-in-javascript-regex 2 | export function escapeRegExp(input: string) { 3 | return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string 4 | } 5 | -------------------------------------------------------------------------------- /src/lib/utils/merge-content.ts: -------------------------------------------------------------------------------- 1 | import { getEntryPatternByHref } from "./markdown"; 2 | 3 | export function mergeContent(newEntryHref: string, newEntry: string, existinContent: string): string { 4 | const replaceResult = existinContent.replace(getEntryPatternByHref(newEntryHref), newEntry); 5 | return replaceResult === existinContent ? `${newEntry}\n${existinContent}` : replaceResult; 6 | } 7 | -------------------------------------------------------------------------------- /src/lib/utils/fit-textarea-to-content.ts: -------------------------------------------------------------------------------- 1 | export function fitTextareaToContent() { 2 | const fitContainer = document.querySelectorAll(".js-textarea-fit-container"); 3 | 4 | fitContainer.forEach((grower) => { 5 | const textarea = grower.querySelector("textarea"); 6 | if (textarea) { 7 | (grower as HTMLDivElement).dataset.replicatedValue = textarea.value; 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["DOM", "DOM.Iterable", "ESNext", "WebWorker"], 5 | "skipLibCheck": true, 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "module": "ESNext", 9 | "moduleResolution": "Node", 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "verbatimModuleSyntax": true, 13 | "allowSyntheticDefaultImports": true, 14 | "noEmit": true 15 | }, 16 | "include": ["src"] 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/utils/tags.ts: -------------------------------------------------------------------------------- 1 | /** Parse a markdown string for all words prefixed by hashtag(#). Ignore words that are inside parentheses and brackets */ 2 | export async function getUniqueTagsFromMarkdownString(markdownString: string) { 3 | // negative look ahead to rule out any hashtags inside parenthesis 4 | const hashTags = markdownString.match(/(?!.*(?:\)|]))#([a-z0-9_-]+)/g) || []; 5 | const textTags = hashTags.map((tag) => tag.split("#")[1]); 6 | const uniqueTags = [...new Set(textTags)].sort(); 7 | 8 | return uniqueTags; 9 | } 10 | -------------------------------------------------------------------------------- /src/lib/sites/sites.ts: -------------------------------------------------------------------------------- 1 | import { defaultSiteConfig } from "./domains/default"; 2 | import { githubSiteConfig } from "./domains/github"; 3 | import { youtubeSiteConfig } from "./domains/youtube"; 4 | 5 | export interface SiteConfig { 6 | siteMatcher: SiteMatcher; 7 | titleExtractors: StringExtractor[]; 8 | urlExtractors: StringExtractor[]; 9 | cacheKeyExtractors: StringExtractor[]; 10 | } 11 | export type SiteMatcher = (document: Document) => boolean; 12 | 13 | export type Extractor = (document: Document) => T | undefined; 14 | 15 | // Interfaces 16 | export type StringExtractor = Extractor; 17 | 18 | export const siteConfigs = [youtubeSiteConfig, githubSiteConfig, defaultSiteConfig]; 19 | -------------------------------------------------------------------------------- /src/lib/sites/domains/youtube.ts: -------------------------------------------------------------------------------- 1 | import type { SiteConfig, StringExtractor } from "../sites"; 2 | import { defaultSiteConfig, docTitle, locationHref } from "./default"; 3 | 4 | const youtubeH1TitleExtractor: StringExtractor = (document) => 5 | document.querySelector("h1:not([hidden])")?.innerText?.trim(); 6 | 7 | // YouTube does not refresh most of the fields in the document html when user navigates to a different video 8 | export const youtubeSiteConfig: SiteConfig = { 9 | siteMatcher: (document) => document.location.hostname.includes("youtube.com"), 10 | urlExtractors: [locationHref], 11 | cacheKeyExtractors: defaultSiteConfig.cacheKeyExtractors, 12 | titleExtractors: [youtubeH1TitleExtractor, docTitle], 13 | }; 14 | -------------------------------------------------------------------------------- /src/lib/utils/lazy-apply.ts: -------------------------------------------------------------------------------- 1 | export type Callable = (...args: T) => K; 2 | 3 | /** 4 | * Given an array of functions, call them one by one 5 | * with the provides array of arguments, until a truthy value is returned. 6 | * Truthy value is any value/object that is not one of `undefined`, `null`, `0`, `""`, `false` 7 | * @returns the first truthy return value from the function(s) called. 8 | */ 9 | export function lazyApply( 10 | fnCalls: Callable[], 11 | args: ArgsType 12 | ): ReturnType | undefined { 13 | for (let fnCall of fnCalls) { 14 | const returnValue = fnCall.apply(null, args); 15 | if (returnValue) { 16 | return returnValue; 17 | } 18 | } 19 | 20 | return undefined; 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/utils/user-options.ts: -------------------------------------------------------------------------------- 1 | export interface UserOptions { 2 | tagOptions: string[]; 3 | accessToken: string; 4 | username: string; 5 | repo: string; 6 | filename: string; 7 | } 8 | 9 | export async function getUserOptions(): Promise { 10 | const options = await chrome.storage.sync.get(["accessToken", "tagOptions", "username", "repo", "filename"]); 11 | 12 | const { accessToken = "", username = "", repo = "", filename = "README.md", tagOptions = [] } = options; 13 | const safeOptions: UserOptions = { 14 | accessToken, 15 | username, 16 | repo, 17 | filename, 18 | tagOptions: tagOptions, 19 | }; 20 | 21 | return safeOptions; 22 | } 23 | 24 | export async function setUserOptions(update: Partial) { 25 | return chrome.storage.sync.set(update); 26 | } 27 | -------------------------------------------------------------------------------- /src/lib/utils/base64.ts: -------------------------------------------------------------------------------- 1 | // Reference: https://stackoverflow.com/questions/30106476/using-javascripts-atob-to-decode-base64-doesnt-properly-decode-utf-8-strings#answer-30106551 2 | export function b64EncodeUnicode(str: string) { 3 | // first we use encodeURIComponent to get percent-encoded UTF-8, 4 | // then we convert the percent encodings into raw bytes which 5 | // can be fed into btoa. 6 | return btoa( 7 | encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function toSolidBytes(match, p1) { 8 | return String.fromCharCode(("0x" + p1) as any); 9 | }) 10 | ); 11 | } 12 | 13 | export function b64DecodeUnicode(str: string) { 14 | // Going backwards: from bytestream, to percent-encoding, to original string. 15 | return decodeURIComponent( 16 | atob(str) 17 | .split("") 18 | .map(function (c) { 19 | return "%" + ("00" + c.charCodeAt(0).toString(16)).slice(-2); 20 | }) 21 | .join("") 22 | ); 23 | } 24 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Memo", 3 | "version": "3.2.0", 4 | "description": "An in-browser bookmark manager with GitHub backend", 5 | "manifest_version": 3, 6 | "action": { 7 | "default_title": "Memo", 8 | "default_popup": "popup.html", 9 | "default_icon": { 10 | "16": "assets/osmosmemo-icon-16.png", 11 | "24": "assets/osmosmemo-icon-24.png", 12 | "32": "assets/osmosmemo-icon-32.png" 13 | } 14 | }, 15 | "options_ui": { 16 | "page": "options.html" 17 | }, 18 | "icons": { 19 | "48": "assets/osmosmemo-icon-48.png", 20 | "96": "assets/osmosmemo-icon-96.png", 21 | "128": "assets/osmosmemo-icon-128.png" 22 | }, 23 | "permissions": [ 24 | "activeTab", 25 | "storage", 26 | "scripting" 27 | ], 28 | "commands": { 29 | "_execute_action": { 30 | "suggested_key": { 31 | "default": "Alt+Shift+D" 32 | }, 33 | "description": "Capture current page" 34 | } 35 | } 36 | } -------------------------------------------------------------------------------- /src/lib/utils/markdown.ts: -------------------------------------------------------------------------------- 1 | import { escapeRegExp } from "./regexp"; 2 | 3 | export function getEntryPatternByHref(href: string): RegExp { 4 | const existingItemPattern = String.raw`^- \[.+\]\(${escapeRegExp(href)}\).*$`; 5 | return new RegExp(existingItemPattern, "m"); 6 | } 7 | 8 | export interface ParsedEntry { 9 | href: string; 10 | title: string; 11 | description: string; 12 | tags: string[]; 13 | } 14 | 15 | /** Given a singple single markdown, parse its field */ 16 | export function parseEntry(markdown: string): ParsedEntry | null { 17 | const linkPattern = /^- \[(.+)\]\((.+)\)/; 18 | const linkMatch = markdown.match(linkPattern); 19 | if (!linkMatch) return null; 20 | 21 | const [full, title, href] = linkMatch; 22 | const remaining = markdown.slice(full.length); 23 | 24 | const tagsPattern = /#[^\s]+[\s]*$/; 25 | const tagsMatch = remaining.match(tagsPattern); 26 | const tags = tagsMatch ? tagsMatch[0].trim().split("#").filter(Boolean) : []; 27 | 28 | const description = remaining.slice(0, tagsMatch?.index).trim(); 29 | 30 | return { title, href, tags, description }; 31 | } 32 | -------------------------------------------------------------------------------- /scripts/pack.js: -------------------------------------------------------------------------------- 1 | import assert from "assert/strict"; 2 | import { exec } from "child_process"; 3 | import { readFile } from "fs/promises"; 4 | import path from "path"; 5 | import { promisify } from "util"; 6 | 7 | const execAsync = promisify(exec); 8 | 9 | assert(process.argv.includes("--dir"), "Specify dir to be packed: --dir "); 10 | const dir = process.argv[process.argv.indexOf("--dir") + 1]; 11 | 12 | /** 13 | * @param {string} dir 14 | */ 15 | async function pack(dir) { 16 | console.log("[pack] extension dir", path.resolve(dir)); 17 | const manifest = await readJson(path.resolve(dir, "manifest.json")); 18 | const version = manifest.version; 19 | const packageName = await readJson("package.json").then((pkg) => pkg.name); 20 | 21 | const outFilename = `${packageName}-${version}.chrome.zip`; 22 | 23 | await execAsync(`zip -r ../${outFilename} .`, { cwd: dir }); 24 | 25 | console.log(`[pack] packed: ${outFilename}`); 26 | } 27 | 28 | /** 29 | * 30 | * @param {string} path 31 | */ 32 | async function readJson(path) { 33 | return JSON.parse(await readFile(path, "utf-8")); 34 | } 35 | 36 | pack(dir); 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 osmoscraft 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /scripts/copy-assets.js: -------------------------------------------------------------------------------- 1 | import assert from "assert/strict"; 2 | import fs from "fs/promises"; 3 | 4 | assert(process.argv.includes("--src"), "Specify src dir: --src "); 5 | assert(process.argv.includes("--target"), "Specify target dir: --target "); 6 | 7 | const isWatch = process.argv.includes("--watch"); 8 | const srcDir = process.argv[process.argv.indexOf("--src") + 1]; 9 | const targetDir = process.argv[process.argv.indexOf("--target") + 1]; 10 | 11 | /** 12 | * @param {string} srcDir 13 | * @param {string} targetDir 14 | * @param {boolean} isWatch 15 | */ 16 | async function copyFilesOnChangeRecursive(srcDir, targetDir, isWatch) { 17 | await fs.cp(srcDir, targetDir, { recursive: true }); 18 | console.log(`[copy-assets] Copied assets`); 19 | 20 | if (!isWatch) return; 21 | 22 | const ac = new AbortController(); 23 | const { signal } = ac; 24 | const changeStream = fs.watch(srcDir, { recursive: false, signal }); 25 | 26 | try { 27 | for await (const _change of changeStream) { 28 | ac.abort(); 29 | copyFilesOnChangeRecursive(srcDir, targetDir, isWatch); 30 | } 31 | } catch { 32 | // ignore the error that abort throws 33 | } 34 | } 35 | 36 | copyFilesOnChangeRecursive(srcDir, targetDir, isWatch); 37 | -------------------------------------------------------------------------------- /src/lib/sites/domains/default.ts: -------------------------------------------------------------------------------- 1 | import type { SiteConfig, StringExtractor } from "../sites"; 2 | 3 | // Title 4 | export const ogTitle: StringExtractor = (document) => 5 | document.querySelector(`meta[property="og:title"]`)?.getAttribute("content")?.trim(); 6 | export const twitterTitle: StringExtractor = (document) => 7 | document.querySelector(`meta[name="twitter:title"]`)?.getAttribute("content")?.trim(); 8 | export const docTitle: StringExtractor = (document) => document.querySelector("title")?.innerText?.trim(); 9 | export const h1Title: StringExtractor = (document) => document.querySelector("h1")?.innerText?.trim(); 10 | 11 | // Href 12 | export const canonicalUrl: StringExtractor = (document) => 13 | document.querySelector(`link[rel="canonical"]`)?.getAttribute("href")?.trim(); 14 | export const locationHref: StringExtractor = (document) => document.location.href; 15 | 16 | // Cache key 17 | export const locationHrefCacheKey: StringExtractor = (document) => document.location.href; 18 | 19 | // Site level 20 | export const defaultSiteConfig: SiteConfig = { 21 | siteMatcher: () => true, // catch all 22 | urlExtractors: [canonicalUrl, locationHref], 23 | cacheKeyExtractors: [locationHrefCacheKey], 24 | titleExtractors: [ogTitle, twitterTitle, docTitle, h1Title], 25 | }; 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "osmosmemo", 3 | "version": "1.0.0", 4 | "description": "A chrome extension that summarizes the current page into a GitHub markdown file", 5 | "private": true, 6 | "type": "module", 7 | "scripts": { 8 | "dev": "rm -rf dist && concurrently --raw npm:dev:*", 9 | "build": "rm -rf dist && concurrently --raw npm:build:* && npm run pack", 10 | "dev:pages": "npm run build:pages -- --watch", 11 | "dev:assets": "npm run build:assets -- --watch", 12 | "build:pages": "esbuild src/content-script.ts src/options.ts src/popup.ts --format=esm --sourcemap --bundle --outdir=dist/unpacked", 13 | "build:assets": "node scripts/copy-assets.js --src public --target dist/unpacked", 14 | "pack": "node scripts/pack.js --dir dist/unpacked", 15 | "test": "tsx src/index.tests.ts", 16 | "test:watch": "tsx --watch src/index.tests.ts" 17 | }, 18 | "repository": { 19 | "type": "git", 20 | "url": "git+https://github.com/osmoscraft/osmosmemo.git" 21 | }, 22 | "keywords": [], 23 | "author": "osmoscraft", 24 | "license": "MIT", 25 | "bugs": { 26 | "url": "https://github.com/osmoscraft/osmosmemo/issues" 27 | }, 28 | "homepage": "https://github.com/osmoscraft/osmosmemo#readme", 29 | "devDependencies": { 30 | "@types/chrome": "^0.0.279", 31 | "@types/node": "^22.8.6", 32 | "concurrently": "^9.0.1", 33 | "esbuild": "^0.24.0", 34 | "tsx": "^4.19.2" 35 | } 36 | } -------------------------------------------------------------------------------- /src/popup.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from "./popup/controller"; 2 | import { Model, type CacheableModel } from "./popup/model"; 3 | import { View } from "./popup/view"; 4 | 5 | const model = new Model(); 6 | const view = new View(); 7 | const controller = new Controller(model, view); 8 | 9 | async function initialize() { 10 | /* Step 1 - Setup listener for the message from content script */ 11 | await chrome.runtime.onMessage.addListener((request, sender) => { 12 | if (request.command === "metadata-ready") { 13 | controller.onData(request.data as CacheableModel); 14 | } 15 | if (request.command === "cached-model-ready") { 16 | const cachedModel = request.data as CacheableModel; 17 | controller.onCache(cachedModel); 18 | } 19 | }); 20 | 21 | /* Step 2 - Inject content script into active tab */ 22 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 23 | const currentTabId = tabs?.[0]?.id; 24 | if (!currentTabId) { 25 | console.error(`[popup] cannot get model. Activie tab does not exist.`); 26 | return; 27 | } 28 | 29 | await chrome.scripting.executeScript({ 30 | target: { tabId: currentTabId }, 31 | files: ["content-script.js"], 32 | }); 33 | 34 | /* Step 3 - Send out request to content script */ 35 | chrome.tabs.sendMessage(currentTabId, { command: "get-model" }); 36 | } 37 | 38 | initialize(); 39 | 40 | // debug 41 | // window.model = model; 42 | // window.view = view; 43 | // window.controller = controller; 44 | -------------------------------------------------------------------------------- /src/lib/utils/test/lazy-apply.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, strictEqual } from "node:assert"; 2 | import { describe, it } from "node:test"; 3 | import { lazyApply } from "../lazy-apply"; 4 | 5 | describe("lazyApply", () => { 6 | it("return undefined for empty input", async () => { 7 | await strictEqual(lazyApply([], []), undefined); 8 | }); 9 | 10 | it("return undefined if function return undefined", async () => { 11 | await strictEqual(lazyApply([() => undefined], []), undefined); 12 | }); 13 | 14 | it("return undefined if multiple functions return falsy value", async () => { 15 | await strictEqual(lazyApply([() => undefined, () => false, () => "", () => null, () => 0], []), undefined); 16 | }); 17 | 18 | it("return calls function with args", async () => { 19 | await deepStrictEqual(lazyApply([(...args) => args], ["hello", "world"]), ["hello", "world"]); 20 | }); 21 | 22 | it("returns first defined value", async () => { 23 | await strictEqual(lazyApply([() => undefined, () => "hello", () => "world"], []), "hello"); 24 | }); 25 | 26 | it("does not call subsequent function after defined value is returned", async () => { 27 | let isTargetFnCalled = false; 28 | let isSubsequentFnCalled = false; 29 | lazyApply( 30 | [ 31 | () => { 32 | isTargetFnCalled = true; 33 | return "hello"; 34 | }, 35 | () => { 36 | isSubsequentFnCalled = true; 37 | return "world"; 38 | }, 39 | ], 40 | [] 41 | ); 42 | 43 | await strictEqual(isTargetFnCalled, true); 44 | await strictEqual(isSubsequentFnCalled, false); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /src/lib/sites/domains/github.ts: -------------------------------------------------------------------------------- 1 | import type { SiteConfig, StringExtractor } from "../sites"; 2 | import { defaultSiteConfig } from "./default"; 3 | 4 | export const githubTitleExtractor: StringExtractor = (document) => { 5 | const githubPathname = parseGithubPathname(document.location.pathname); 6 | if (!githubPathname) return undefined; 7 | return `${githubPathname.owner}/${githubPathname.repo}`; 8 | }; 9 | 10 | export const githubSiteConfig: SiteConfig = { 11 | siteMatcher: matchGithubRepoRoot, 12 | urlExtractors: defaultSiteConfig.urlExtractors, 13 | cacheKeyExtractors: defaultSiteConfig.cacheKeyExtractors, 14 | titleExtractors: [githubTitleExtractor, ...defaultSiteConfig.titleExtractors], 15 | }; 16 | 17 | function matchGithubRepoRoot(document: Document) { 18 | if (document.location.hostname !== "github.com") return false; 19 | 20 | const parsedPathname = parseGithubPathname(document.location.pathname); 21 | if (!parsedPathname) return false; 22 | 23 | const parsedTitle = parseGithubRepoTitle(document.title); 24 | if (!parsedTitle) return false; 25 | 26 | return parsedPathname.owner === parsedTitle.owner && parsedPathname.repo === parsedTitle.repo; 27 | } 28 | 29 | function parseGithubPathname(pathname: string) { 30 | const segments = pathname.split("/").filter(Boolean); 31 | if (segments.length !== 2) return null; 32 | 33 | const [owner, repo] = segments; 34 | return { 35 | owner, 36 | repo, 37 | }; 38 | } 39 | 40 | function parseGithubRepoTitle(title: string) { 41 | const matched = title.match(/([^\/]+)\/([^\/]+):\s(.+)/); 42 | if (!matched) return null; 43 | 44 | const [, owner, repo, tagline] = matched; 45 | 46 | return { 47 | owner, 48 | repo, 49 | tagline, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /src/popup/model.ts: -------------------------------------------------------------------------------- 1 | export interface FullModel extends CacheableModel { 2 | description: string; 3 | tags: string[]; 4 | tagOptions: string[]; 5 | /** Markdown string of the entire storage file */ 6 | markdownString?: string; 7 | isSaved?: boolean; 8 | isCacheLoaded?: boolean; 9 | /** URL to the GitHub storage file */ 10 | libraryUrl?: string; 11 | saveStatus: "new" | "saving" | "saved" | "error"; 12 | connectionStatus: "unknown" | "valid" | "error"; 13 | } 14 | 15 | export interface CacheableModel { 16 | title?: string; 17 | href?: string; 18 | cacheKey?: string; 19 | description?: string; 20 | tags?: string[]; 21 | } 22 | 23 | export interface SavedModel { 24 | title: string; 25 | href: string; 26 | description: string; 27 | tags: string[]; 28 | } 29 | 30 | export class Model { 31 | get state(): FullModel { 32 | return this._state; 33 | } 34 | 35 | private _state: FullModel = { 36 | title: undefined, 37 | href: undefined, 38 | cacheKey: undefined, 39 | markdownString: undefined, 40 | description: "", 41 | tags: [], 42 | tagOptions: [], 43 | libraryUrl: undefined, 44 | saveStatus: "new", // 'new' | 'saving' | 'saved' | 'error', 45 | connectionStatus: "unknown", // 'unknown' | 'valid' | 'error' 46 | }; 47 | emitter = document.createElement("div"); 48 | 49 | getCacheableState(): CacheableModel { 50 | const { title, href, cacheKey, description, tags } = this._state; 51 | return { 52 | title, 53 | href, 54 | cacheKey, 55 | description, 56 | tags, 57 | }; 58 | } 59 | 60 | updateAndCache(delta: Partial) { 61 | this.update(delta, true); 62 | } 63 | 64 | update(delta: Partial, shouldCache = false) { 65 | const previousState = { ...this._state }; 66 | this._state = { ...this._state, ...delta }; 67 | this.emitter.dispatchEvent( 68 | new CustomEvent("update", { 69 | detail: { 70 | state: this._state, 71 | previousState, 72 | shouldCache, 73 | }, 74 | }) 75 | ); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v3.2.1 2 | 3 | - Fixed: Commit message can be empty 4 | 5 | # v3.2.0 6 | 7 | - Added: Generate meaningful commit message based on site content 8 | - Chore: Update dependencies 9 | 10 | # v3.1.1 11 | 12 | - Fixed: GitHub repo url did not extract `owner/repo` as title 13 | 14 | # v3.1.0 15 | 16 | - New: Parse repo name and tagline from GitHub repo URL 17 | - Fixed: Keyboard shortcut suggestion per Manifest v3 spec 18 | - Chore: Dependency reduction and updates 19 | - Chore: Modern build scripts with Node.js 20 API 20 | 21 | # v3.0.1 22 | 23 | - Fixed: Capture status was stale when link input changes 24 | 25 | # v3.0.0 26 | 27 | - New: Migrated to Web Manifest v3 for compatibility with Chrome 28 | - New: Detect existing URLs for which "Save" becomes "Update" 29 | - Changed: Link input is moved above Title input 30 | 31 | # v2.3.3 32 | 33 | - Fixed: "Saved" status is displayed before the API request is completed 34 | - Chore: Update testing library to vitest 35 | 36 | # v2.3.2 37 | 38 | - Chore: Migrated deprecated web extension polyfill 39 | 40 | # v2.3.1 41 | 42 | - Fixed: Wrong title displayed for YouTube after client-side navigation between videos 43 | - Fixed: A HTML syntax error in the added tag element 44 | - Chore: Refactored title/url extraction logic to be extensible 45 | - Chore: Added unit test infrastructure 46 | - Thank you @dinh, @joshatt 47 | 48 | # v2.3.0 49 | 50 | - Added: Support unicode characters in all input fields. e.g., you can use Chinese or even emoji in tags now. 51 | - Fixed: YouTube url missing video IDs. 52 | - Fixed: Typo in "connecting..." status label. 53 | - Thank you @jerrylususu, @dinh 54 | 55 | # v2.2.1 56 | 57 | - Fixed: Case sensitive URLs were transformed to lowercase. 58 | - Thank you @dinh. 59 | 60 | # v2.2.0 61 | 62 | - Added: `Alt+Shift+D` to capture the current page. 63 | - Fixed: A typo on settings UI. 64 | 65 | # v2.1.2 66 | 67 | - Added: Placeholder that reminds you to curate for your future self. 68 | - Changed: More crisp and recognizable logo 69 | - Changed: Spellcheck is now deactivated for url input. 70 | 71 | # v2.1.1 72 | 73 | - Initial public release 74 | -------------------------------------------------------------------------------- /src/content-script.ts: -------------------------------------------------------------------------------- 1 | import { siteConfigs } from "./lib/sites/sites"; 2 | import { lazyApply } from "./lib/utils/lazy-apply"; 3 | import type { CacheableModel } from "./popup/model"; 4 | 5 | declare global { 6 | interface Window { 7 | _osmosmemoInjected?: boolean; 8 | } 9 | } 10 | 11 | function injectContentScript() { 12 | // make sure the content script is injected only on first run 13 | if (window._osmosmemoInjected) return; 14 | window._osmosmemoInjected = true; 15 | 16 | console.log(`[osmos] content-script activated`); 17 | 18 | chrome.runtime.onMessage.addListener((request) => { 19 | if (request.command === "set-cached-model") { 20 | console.log(`[osmos] set cached model`, request.data); 21 | sessionStorage.setItem("cached-model", JSON.stringify(request.data)); 22 | } else if (request.command === "get-model") { 23 | try { 24 | const cachedModelString = sessionStorage.getItem("cached-model"); 25 | if (!cachedModelString) throw new Error("No cache model found"); 26 | const cachedModel: CacheableModel = JSON.parse(cachedModelString); 27 | 28 | if (cachedModel.cacheKey !== getPageCacheKey()) { 29 | throw new Error("Cache invalidated due to key change"); 30 | } 31 | 32 | console.log(`[osmos] get cached model`, cachedModel); 33 | chrome.runtime.sendMessage({ command: "cached-model-ready", data: cachedModel }); 34 | } catch (e) { 35 | const model = getMetadata(); 36 | chrome.runtime.sendMessage({ command: "metadata-ready", data: model }); 37 | console.log(`[osmos] get metadata`, model); 38 | } 39 | } 40 | }); 41 | 42 | function getMetadata(): CacheableModel { 43 | const bestConfig = siteConfigs.find((config) => config.siteMatcher(document))!; 44 | 45 | return { 46 | title: lazyApply(bestConfig.titleExtractors, [document]), 47 | href: lazyApply(bestConfig.urlExtractors, [document]), 48 | cacheKey: lazyApply(bestConfig.cacheKeyExtractors, [document]), 49 | }; 50 | } 51 | 52 | function getPageCacheKey() { 53 | const bestConfig = siteConfigs.find((config) => config.siteMatcher(document))!; 54 | 55 | return lazyApply(bestConfig.cacheKeyExtractors, [document])!; 56 | } 57 | } 58 | 59 | injectContentScript(); 60 | -------------------------------------------------------------------------------- /src/lib/utils/test/parse-entry.test.ts: -------------------------------------------------------------------------------- 1 | import { deepStrictEqual, strictEqual } from "node:assert"; 2 | import { describe, it } from "node:test"; 3 | import { parseEntry } from "../markdown"; 4 | 5 | describe("parseEntry", () => { 6 | it("Empty entry", () => { 7 | strictEqual(parseEntry(""), null); 8 | }); 9 | 10 | it("Simple link", () => { 11 | deepStrictEqual(parseEntry("- [title](http://example.com)"), { 12 | title: "title", 13 | href: "http://example.com", 14 | description: "", 15 | tags: [], 16 | }); 17 | }); 18 | 19 | it("Link with description", () => { 20 | deepStrictEqual(parseEntry("- [title](http://example.com) details"), { 21 | title: "title", 22 | href: "http://example.com", 23 | description: "details", 24 | tags: [], 25 | }); 26 | }); 27 | 28 | it("Link with longer description", () => { 29 | deepStrictEqual(parseEntry("- [title](http://example.com) details and more details"), { 30 | title: "title", 31 | href: "http://example.com", 32 | description: "details and more details", 33 | tags: [], 34 | }); 35 | }); 36 | 37 | it("Link with tags", () => { 38 | deepStrictEqual(parseEntry("- [title](http://example.com) #tag"), { 39 | title: "title", 40 | href: "http://example.com", 41 | description: "", 42 | tags: ["tag"], 43 | }); 44 | }); 45 | 46 | it("Link with multiple tags", () => { 47 | deepStrictEqual(parseEntry("- [title](http://example.com) #tag1#tag2"), { 48 | title: "title", 49 | href: "http://example.com", 50 | description: "", 51 | tags: ["tag1", "tag2"], 52 | }); 53 | }); 54 | 55 | it("Link with everything", () => { 56 | deepStrictEqual(parseEntry("- [title](http://example.com) details #tag1#tag2"), { 57 | title: "title", 58 | href: "http://example.com", 59 | description: "details", 60 | tags: ["tag1", "tag2"], 61 | }); 62 | }); 63 | 64 | it("Link with everything and additional whitespace", () => { 65 | deepStrictEqual(parseEntry("- [title](http://example.com) details #tag1#tag2 "), { 66 | title: "title", 67 | href: "http://example.com", 68 | description: "details", 69 | tags: ["tag1", "tag2"], 70 | }); 71 | }); 72 | 73 | it("Link with everything and lack of whitespace", () => { 74 | deepStrictEqual(parseEntry("- [title](http://example.com)details#tag1#tag2"), { 75 | title: "title", 76 | href: "http://example.com", 77 | description: "details", 78 | tags: ["tag1", "tag2"], 79 | }); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /public/popup.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /public/options.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 |
10 |
11 | 12 | 13 | Join GitHub 14 | 15 | 22 |
23 |
24 |
25 | 26 | 27 | Create repo 30 | 31 | 32 |
33 |
34 |
35 | 36 | 37 | Create token 38 | 39 | 46 |
* The repo scope is required.
47 |
48 |
49 |
50 | 51 | 52 |
53 |
54 |
55 | 56 |
57 |
58 |
59 | 60 |
61 | 62 |
63 |
64 |
65 |
Having trouble? Check these:
66 |
    67 |
  • Verify the Repo exists. You should be able to open https://github.com/USERNAME/REPO
  • 68 |
  • Make sure you have selected the repo scope when creating the personal access token.
  • 69 |
  • 70 | Still having trouble? Find documentation on GitHub. 71 |
  • 72 |
73 |
74 |
75 | 76 | 77 | 78 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | import { getContentString } from "./lib/github/rest-api"; 2 | import { fitTextareaToContent } from "./lib/utils/fit-textarea-to-content"; 3 | import { getUniqueTagsFromMarkdownString } from "./lib/utils/tags"; 4 | import { getUserOptions, setUserOptions } from "./lib/utils/user-options"; 5 | 6 | const optionsForm = document.querySelector(".js-options-form") as HTMLElement; 7 | const connectButtonElement = document.querySelector(".js-connect") as HTMLElement; 8 | const accessTokenElement = document.querySelector(".js-access-token") as HTMLInputElement; 9 | const tagsElement = document.querySelector(".js-tags") as HTMLElement; 10 | const tagCountElement = document.querySelector(".js-tag-count") as HTMLElement; 11 | const usernameElement = document.querySelector(".js-username") as HTMLInputElement; 12 | const repoElement = document.querySelector(".js-repo") as HTMLInputElement; 13 | const filenameElement = document.querySelector(".js-filename") as HTMLInputElement; 14 | 15 | function renderInputField({ element, string }) { 16 | element.value = string; 17 | } 18 | 19 | async function renderAllFields() { 20 | const { accessToken, username, repo, filename } = await getUserOptions(); 21 | 22 | renderInputField({ element: accessTokenElement, string: accessToken }); 23 | renderInputField({ element: usernameElement, string: username }); 24 | renderInputField({ element: repoElement, string: repo }); 25 | renderInputField({ element: filenameElement, string: filename }); 26 | } 27 | 28 | renderAllFields(); 29 | 30 | chrome.storage.onChanged.addListener(function (changes, namespace) { 31 | if (namespace === "sync") { 32 | renderAllFields(); 33 | } 34 | }); 35 | 36 | connectButtonElement.addEventListener("click", async (event) => { 37 | if (!(optionsForm as HTMLFormElement).checkValidity()) return; 38 | event.preventDefault(); 39 | 40 | const accessToken = accessTokenElement.value; 41 | const username = usernameElement.value; 42 | const repo = repoElement.value; 43 | const filename = filenameElement.value; 44 | 45 | connectButtonElement.innerText = "🔗 Connecting…"; 46 | 47 | try { 48 | const markdownString = await getContentString({ accessToken, username, repo, filename }); 49 | connectButtonElement.innerText = "✅ Connected to GitHub"; 50 | setUserOptions({ accessToken, username, repo, filename }); 51 | 52 | const tagOptions = await getUniqueTagsFromMarkdownString(markdownString); 53 | updateTagOptionsPreview(tagOptions); 54 | showConditionalElements("on-success"); 55 | } catch (e) { 56 | connectButtonElement.innerText = "❌ Something went wrong. Try again"; 57 | showConditionalElements("on-error"); 58 | } 59 | }); 60 | 61 | function updateTagOptionsPreview(tags: string[]) { 62 | renderInputField({ element: tagsElement, string: tags.join(", ") }); 63 | tagCountElement.innerText = `${tags.length} found`; 64 | 65 | fitTextareaToContent(); 66 | } 67 | 68 | function showConditionalElements(condition: "on-success" | "on-error") { 69 | (document.querySelectorAll(`[data-show]`) as NodeListOf).forEach((element) => { 70 | if (element.dataset.show === condition) { 71 | element.dataset.showActive = ""; 72 | } else { 73 | delete element.dataset.showActive; 74 | } 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![image](./docs/media/osmosmemo-square-badge.svg)](#get-started) 2 | 3 | # osmos::memo 4 | 5 | A browser bookmark manager optimized for capture and retrieval speed. 6 | 7 | - Extract page title and url into a short markdown snippet. 8 | - One-click to insert the snippet to `README.md` hosted on GitHub. 9 | - Add new tags or reuse the ones from previous snippets. 10 | - Instant search from snippets with the "find on page" utility built into browsers. 11 | 12 | ## Screenshot 13 | 14 | ![image](https://user-images.githubusercontent.com/1895289/115334609-8ffb5180-a150-11eb-97f2-20865fde4ff9.png) 15 | 16 | [My memo since 2018](https://github.com/chuanqisun/memo) 17 | [![image](https://user-images.githubusercontent.com/1895289/115136700-5b638a80-9fd6-11eb-9c12-e53b1e98a1e1.png)](https://github.com/chuanqisun/memo) 18 | 19 | ## Get started 20 | 21 | ### Install 22 | 23 | - [Chrome](https://chrome.google.com/webstore/detail/osmosmemo/chgfencjlhmjhmnnpnlnchglkkdcipii) 24 | - [Firefox](https://addons.mozilla.org/en-US/firefox/addon/osmos-memo) 25 | 26 | ### Connect to GitHub 27 | 28 | - When you active the extension from browser toolbar for the 1st time, click the button to connect to GitHub. 29 | ![image](https://user-images.githubusercontent.com/1895289/115136286-acbe4a80-9fd3-11eb-9c5f-7e14a1e8c38d.png) 30 | - Provide your GitHub username and repo. 31 | - If you don't have a repo yet, it's easiest to [create from the template](https://github.com/login?return_to=%2Fosmoscraft%2Fosmosmemo-template%2Fgenerate). 32 | - You can set the visibility of your repo to either Public or Private. The extension works in both cases. 33 | - Create a new [fine-grained access token](https://github.com/settings/personal-access-tokens/new) for the extension to add content on behalf of you. Make sure you select the correct repo and grant `Contents` permission with `Read and write` access. 34 | ![image](https://github.com/user-attachments/assets/c80429dc-7e08-40c1-921a-cb264f27fde9) 35 | 36 | - Use `README.md` as the storage filename. Other filenames work too but GitHub will not automatically render it as the home page for your repo. 37 | - Click Connect and make sure you get a success message. 38 | ![image](https://user-images.githubusercontent.com/1895289/115334759-cc2eb200-a150-11eb-9a71-1b0372532cfb.png) 39 | - Now navigate to any page and re-open the extension. You will be able to save new content. 40 | ![image](https://user-images.githubusercontent.com/1895289/115136348-10487800-9fd4-11eb-9a40-81382fe5c0fb.png) 41 | 42 | ## FAQ 43 | 44 | ### How to open the extension with keyboard shortcut? 45 | 46 | > By the default, Alt+Shift+D opens the extension. You can customize it with browser extensions settings. 47 | > 48 | > - Chrome and Edge: visit `chrome://extensions/shortcuts` 49 | > - Firefox: visit `about:addons` as shown in [this video](https://bug1303384.bmoattachments.org/attachment.cgi?id=9051647). 50 | 51 | ### How long does it take for a new release to reach my browser? 52 | 53 | - Firefox: from a couple hours to a day 54 | - Chrome: 1-3 days 55 | 56 | ## Ecosystem 57 | 58 | Browse other projects from the [OsmosCraft](https://osmoscraft.org/) ecosystem. 59 | 60 | - Read the web with [Fjord](https://github.com/osmoscraft/fjord) 61 | - Manage bookmarks with [Memo](https://github.com/osmoscraft/osmosmemo) 62 | - Take notes with [Tundra](https://github.com/osmoscraft/tundra) 63 | -------------------------------------------------------------------------------- /src/lib/github/rest-api.ts: -------------------------------------------------------------------------------- 1 | import { b64DecodeUnicode, b64EncodeUnicode } from "../utils/base64.js"; 2 | 3 | export async function getContentString({ accessToken, username, repo, filename }) { 4 | const contents = await getContentsOrCreateNew({ accessToken, username, repo, filename }); 5 | const stringResult = b64DecodeUnicode(contents.content ?? ""); 6 | 7 | return stringResult; 8 | } 9 | 10 | /** 11 | * Insert content at the first line of the file. An EOL character will be automatically added. 12 | * @return Updated full markdown string 13 | */ 14 | export async function updateContent( 15 | { accessToken, username, repo, filename, message }, 16 | updateFunction: (previousContent: string) => string 17 | ) { 18 | // To reduce chance of conflict, the update function runs on fresh data from API 19 | const contents = await getContents({ accessToken, username, repo, filename }); 20 | const previousContent = b64DecodeUnicode(contents.content ?? ""); 21 | const resultContent = updateFunction(previousContent); 22 | 23 | await writeContent({ 24 | accessToken, 25 | username, 26 | repo, 27 | filename, 28 | previousSha: contents.sha, 29 | content: resultContent, 30 | message, 31 | }); 32 | 33 | return resultContent; 34 | } 35 | 36 | /** currently only work with public repos */ 37 | export async function getLibraryUrl({ accessToken, username, repo, filename }) { 38 | const defaultBranch = await getDefaultBranch({ accessToken, username, repo }); 39 | 40 | return `https://github.com/login?return_to=${encodeURIComponent( 41 | `https://github.com/${username}/${repo}/blob/${defaultBranch}/${filename}` 42 | )}`; 43 | } 44 | 45 | async function writeContent({ accessToken, username, repo, filename, previousSha, content, message }) { 46 | return fetch(`https://api.github.com/repos/${username}/${repo}/contents/${filename}`, { 47 | method: "PUT", 48 | headers: new Headers({ 49 | Authorization: "Basic " + btoa(`${username}:${accessToken}`), 50 | "Content-Type": "application/json", 51 | }), 52 | body: JSON.stringify({ 53 | message, 54 | content: b64EncodeUnicode(content), 55 | sha: previousSha, 56 | }), 57 | }); 58 | } 59 | 60 | async function getContentsOrCreateNew({ accessToken, username, repo, filename }) { 61 | let response = await getContentsInternal({ accessToken, username, repo, filename }); 62 | 63 | if (response.status === 404) { 64 | console.log(`[rest-api] ${filename} does not exist. Create new`); 65 | response = await writeContent({ 66 | accessToken, 67 | username, 68 | repo, 69 | filename, 70 | previousSha: undefined, 71 | content: "", 72 | message: "initialize memo", 73 | }); 74 | } 75 | 76 | if (!response.ok) throw new Error("create-contents-failed"); 77 | 78 | return getContents({ accessToken, username, repo, filename }); 79 | } 80 | 81 | async function getContents({ accessToken, username, repo, filename }) { 82 | const response = await getContentsInternal({ accessToken, username, repo, filename }); 83 | if (!response.ok) throw new Error("get-contents-failed"); 84 | 85 | return response.json(); 86 | } 87 | 88 | async function getDefaultBranch({ accessToken, username, repo }): Promise { 89 | try { 90 | const response = await fetch(`https://api.github.com/repos/${username}/${repo}/branches`, { 91 | headers: new Headers({ 92 | Authorization: "Basic " + btoa(`${username}:${accessToken}`), 93 | "Content-Type": "application/json", 94 | }), 95 | }); 96 | 97 | const branches = (await response.json()) as any[]; 98 | if (branches?.length) { 99 | return branches[0].name as string; 100 | } 101 | throw new Error("No branch found"); 102 | } catch (error) { 103 | return null; 104 | } 105 | } 106 | 107 | async function getContentsInternal({ accessToken, username, repo, filename }) { 108 | return await fetch(`https://api.github.com/repos/${username}/${repo}/contents/${filename}`, { 109 | headers: new Headers({ 110 | Authorization: "Basic " + btoa(`${username}:${accessToken}`), 111 | "Content-Type": "application/json", 112 | }), 113 | }); 114 | } 115 | -------------------------------------------------------------------------------- /docs/media/osmosmemo-square-badge.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /public/styles.css: -------------------------------------------------------------------------------- 1 | body { 2 | --app-bg: #eee; 3 | --control-border-c: #ccc; 4 | --control-bg: #fff; 5 | --control-bg-hover: #f7f7f7; 6 | --default-text-c: #333; 7 | --secondary-text-c: #666; 8 | --standard-control-height: 30px; 9 | --standard-border-radius: 4px; 10 | --label-font-size: 12px; 11 | --label-font-weight: 600; 12 | 13 | background-color: var(--app-bg); 14 | color: var(--default-text-c); 15 | margin: 16px; 16 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif, "Apple Color Emoji", 17 | "Segoe UI Emoji", "Segoe UI Symbol"; 18 | } 19 | 20 | * { 21 | color: inherit; 22 | font-family: inherit; 23 | box-sizing: border-box; 24 | } 25 | 26 | .popup-container { 27 | width: 30em; 28 | display: grid; 29 | grid-auto-rows: auto; 30 | } 31 | 32 | hr { 33 | border-width: 0; 34 | margin: 4px 0; 35 | } 36 | 37 | label { 38 | font-weight: var(--label-font-weight); 39 | font-size: var(--label-font-size); 40 | } 41 | 42 | output, 43 | input, 44 | textarea { 45 | border: 1px solid var(--control-border-c); 46 | border-radius: var(--standard-border-radius); 47 | padding: 4px 8px; 48 | resize: none; 49 | font-size: 14px; 50 | line-height: 20px; 51 | } 52 | textarea[readonly] { 53 | background-color: transparent; 54 | } 55 | input { 56 | height: var(--standard-control-height); 57 | } 58 | input.as-partial-l { 59 | border-top-right-radius: 0; 60 | border-bottom-right-radius: 0; 61 | } 62 | 63 | .button, 64 | button { 65 | display: inline-flex; 66 | align-items: center; 67 | justify-content: center; 68 | height: var(--standard-control-height); 69 | font-size: 14px; 70 | font-weight: 600; 71 | background-color: var(--control-bg); 72 | border: 1px solid var(--control-border-c); 73 | border-radius: var(--standard-border-radius); 74 | text-decoration: none; 75 | } 76 | .button:hover, 77 | button:hover { 78 | background-color: var(--control-bg-hover); 79 | } 80 | .button[hidden], 81 | button[hidden] { 82 | display: none; 83 | } 84 | 85 | button.as-partial-r { 86 | border-top-left-radius: 0; 87 | border-bottom-left-radius: 0; 88 | border-left-width: 0; 89 | } 90 | 91 | .added-tags { 92 | width: 100%; 93 | } 94 | 95 | .added-tag { 96 | border-radius: calc(var(--standard-control-height) / 2); 97 | height: calc(var(--standard-control-height) - 4px); 98 | margin: 0 2px 4px 0; 99 | padding: 0 8px; 100 | } 101 | 102 | input[name="tag"] { 103 | width: 10rem; 104 | } 105 | 106 | .add-tag { 107 | display: flex; 108 | flex-wrap: wrap; 109 | } 110 | 111 | .field { 112 | display: grid; 113 | grid-template: 114 | "label action" auto 115 | "input input" auto / 1fr auto; 116 | gap: 4px; 117 | } 118 | 119 | .field__label { 120 | grid-area: label; 121 | } 122 | 123 | .field__action { 124 | grid-area: action; 125 | font-size: var(--label-font-size); 126 | } 127 | 128 | .field__input { 129 | grid-area: input; 130 | } 131 | 132 | .actions { 133 | cursor: default; 134 | display: grid; 135 | grid-template-columns: 1fr 1fr 40px; 136 | grid-gap: 4px; 137 | } 138 | 139 | .actions.has-error { 140 | grid-template-columns: 1fr; 141 | } 142 | 143 | .open-options:not(.has-error) .open-options__show-when-error { 144 | display: none; 145 | } 146 | .open-options.has-error .open-options__show-when-valid { 147 | display: none; 148 | } 149 | 150 | /** Specific to options page */ 151 | .options-container { 152 | margin: auto; 153 | max-width: 64em; 154 | display: grid; 155 | grid-auto-rows: auto; 156 | } 157 | 158 | /** 159 | * https://css-tricks.com/the-cleanest-trick-for-autogrowing-textareas/ 160 | */ 161 | .js-textarea-fit-container { 162 | /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ 163 | display: grid; 164 | } 165 | .js-textarea-fit-container::after { 166 | /* Note the weird space! Needed to preventy jumpy behavior */ 167 | content: attr(data-replicated-value) " "; 168 | 169 | /* This is how textarea text behaves */ 170 | white-space: pre-wrap; 171 | 172 | /* Hidden from view, clicks, and screen readers */ 173 | visibility: hidden; 174 | } 175 | .js-textarea-fit-container > textarea { 176 | /* You could leave this, but after a user resizes, then it ruins the auto sizing */ 177 | resize: none; 178 | 179 | /* Firefox shows scrollbar on growth, you can hide like this. */ 180 | overflow: hidden; 181 | } 182 | .js-textarea-fit-container > textarea, 183 | .js-textarea-fit-container::after { 184 | /* Identical styling required!! */ 185 | border: 1px solid var(--control-border-c); 186 | border-radius: var(--standard-border-radius); 187 | padding: 4px 8px; 188 | resize: none; 189 | font-size: 14px; 190 | line-height: 20px; 191 | 192 | overflow-wrap: anywhere; /* Not supported in safari but the extension is not available in safari. */ 193 | 194 | /* Place on top of each other */ 195 | grid-area: 1 / 1 / 2 / 2; 196 | } 197 | 198 | /* utils */ 199 | [data-show]:not([data-show-active]) { 200 | display: none; 201 | } 202 | 203 | .trouble-shooting-title { 204 | font-size: 14px; 205 | font-weight: 600; 206 | } 207 | 208 | .trouble-shooting-list { 209 | margin-top: 8px; 210 | font-size: 14px; 211 | padding-left: 24px; 212 | } 213 | -------------------------------------------------------------------------------- /src/popup/controller.ts: -------------------------------------------------------------------------------- 1 | import { getContentString, getLibraryUrl, updateContent } from "../lib/github/rest-api"; 2 | import { getEntryPatternByHref, parseEntry } from "../lib/utils/markdown"; 3 | import { mergeContent } from "../lib/utils/merge-content"; 4 | import { truncateString } from "../lib/utils/string"; 5 | import { getUniqueTagsFromMarkdownString } from "../lib/utils/tags"; 6 | import { getUserOptions } from "../lib/utils/user-options"; 7 | import type { CacheableModel, FullModel, Model } from "./model"; 8 | import type { View } from "./view"; 9 | 10 | export class Controller { 11 | constructor(private model: Model, private view: View) { 12 | this.init(); 13 | } 14 | 15 | async init() { 16 | this.view.handleOutput({ 17 | onTitleChange: (title) => this.model.updateAndCache({ title }), 18 | onLinkChange: (href) => { 19 | // when link changes, Saved status must be recalculated 20 | const savedModel = this.findSavedModel(href, this.model.state.markdownString) ?? undefined; 21 | this.model.update({ ...savedModel, isSaved: !!savedModel }); 22 | this.model.updateAndCache({ href }); 23 | }, 24 | onDescriptionChange: (description) => this.model.updateAndCache({ description }), 25 | onAddTag: (tag) => this.model.updateAndCache({ tags: [...this.model.state.tags, tag] }), 26 | onRemoveTagByIndex: (index) => 27 | this.model.updateAndCache({ tags: this.model.state.tags.filter((_, i) => i !== index) }), 28 | onSave: () => this.onSave(), 29 | }); 30 | 31 | this.model.emitter.addEventListener("update", (e) => { 32 | const { state, previousState, shouldCache } = (e as CustomEvent).detail; 33 | this.view.render({ state, previousState }); 34 | if (shouldCache) { 35 | this.cacheModel(); 36 | } 37 | }); 38 | 39 | const optionsData = await getUserOptions(); 40 | this.model.update({ tagOptions: optionsData.tagOptions }); 41 | 42 | const { accessToken, username, repo, filename } = optionsData; 43 | try { 44 | const markdownString = await getContentString({ accessToken, username, repo, filename }); 45 | const libraryUrl = await getLibraryUrl({ accessToken, username, repo, filename }); 46 | const tagOptions = await getUniqueTagsFromMarkdownString(markdownString); 47 | const savedModel = this.findSavedModel(this.model.state.href, markdownString) ?? undefined; 48 | this.model.update({ 49 | tagOptions, 50 | libraryUrl, 51 | connectionStatus: "valid", 52 | markdownString, 53 | isSaved: !!savedModel, 54 | ...(this.model.state.isCacheLoaded ? undefined : savedModel), // Cache takes precedence over saved model 55 | }); 56 | console.log(`[controller] Model updated from GitHub`); 57 | } catch (e) { 58 | this.model.update({ connectionStatus: "error" }); 59 | } 60 | } 61 | 62 | async onSave() { 63 | if (!this.view.validateForm()) { 64 | return; 65 | } 66 | 67 | this.model.update({ saveStatus: "saving" }); 68 | const optionsData = await getUserOptions(); 69 | try { 70 | const { accessToken, username, repo, filename } = optionsData; 71 | const { title, href, description, tags } = this.model.state; 72 | const newEntryString = this.view.getPreviewOutput(title, href, description, tags); 73 | const mergeWithExisting = mergeContent.bind(null, href!, newEntryString); 74 | const message = truncateString( 75 | [title, description, href] 76 | .map((str) => str?.trim()) 77 | .filter(Boolean) 78 | .join(" "), 79 | 64 80 | ); 81 | const updatedContent = await updateContent({ accessToken, username, repo, filename, message }, mergeWithExisting); 82 | this.model.update({ saveStatus: "saved", markdownString: updatedContent, isSaved: true }); 83 | } catch { 84 | this.model.update({ saveStatus: "error" }); 85 | } 86 | } 87 | 88 | onData({ title, href, cacheKey }: Partial) { 89 | const savedModel = this.findSavedModel(href, this.model.state.markdownString) ?? undefined; 90 | this.model.update({ title: title, href, cacheKey, saveStatus: "new", ...savedModel, isSaved: !!savedModel }); 91 | console.log(`[controller] Model updated from new page parser`); 92 | } 93 | 94 | onCache(cachedModel: CacheableModel) { 95 | const savedModel = this.findSavedModel(cachedModel.href!, this.model.state.markdownString) ?? undefined; 96 | // Let cache override any existing state 97 | this.model.update({ ...cachedModel, isCacheLoaded: true, isSaved: !!savedModel }); 98 | console.log(`[controller] Model updated from session cache`); 99 | } 100 | 101 | private findSavedModel(href?: string, remoteMarkdown?: string) { 102 | if (!remoteMarkdown) return null; 103 | if (!href) return null; 104 | 105 | const match = remoteMarkdown?.match(getEntryPatternByHref(href)); 106 | if (!match) return null; 107 | 108 | return match ? parseEntry(match[0]) : null; 109 | } 110 | 111 | async cacheModel() { 112 | const tabs = await chrome.tabs.query({ active: true, currentWindow: true }); 113 | if (!tabs?.[0]?.id) { 114 | console.error(`[controller] cannot cache model. Activie tab does not exist.`); 115 | return; 116 | } 117 | 118 | chrome.tabs.sendMessage(tabs[0].id, { command: "set-cached-model", data: this.model.getCacheableState() }); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/popup/view.ts: -------------------------------------------------------------------------------- 1 | import { fitTextareaToContent } from "../lib/utils/fit-textarea-to-content"; 2 | import type { FullModel } from "./model"; 3 | 4 | const $ = document.querySelector.bind(document); 5 | 6 | /* Input elements */ 7 | const formElement = $(".js-creation-form") as HTMLFormElement; 8 | const titleInputElement = $(".js-title") as HTMLInputElement; 9 | const linkInputElement = $(".js-link") as HTMLInputElement; 10 | const existingLinkMarker = $(".js-existing-link-marker") as HTMLSpanElement; 11 | const descriptionInputElement = $(".js-description") as HTMLInputElement; 12 | const previewElement = $(".js-preview") as HTMLInputElement; 13 | const addedTagsElement = $(".added-tags") as HTMLElement; 14 | const tagInputElement = $(".js-tag-input") as HTMLInputElement; 15 | const tagOptionsElement = $(".js-tag-options") as HTMLDataListElement; 16 | const addTagButtonElement = $(".js-add-tag-button") as HTMLButtonElement; 17 | const actionsElement = $(".js-actions") as HTMLDivElement; 18 | const saveButtonElement = $(".js-save") as HTMLButtonElement; 19 | const openOptionsButtonElement = $(".js-open-options") as HTMLButtonElement; 20 | const openLibraryLinkElement = $(".js-open-library") as HTMLAnchorElement; 21 | 22 | const saveStatusDisplayStrings = new Map([ 23 | ["new", "💾 Save"], 24 | ["saving", "💾 Saving…"], 25 | ["saved", "✅ Saved"], 26 | ["error", "❌ Error"], 27 | ]); 28 | 29 | export class View { 30 | constructor() { 31 | // fix me: chromium edge seems to be flaky with autosize 32 | fitTextareaToContent(); 33 | } 34 | 35 | validateForm() { 36 | return formElement.checkValidity(); 37 | } 38 | 39 | handleOutput({ onTitleChange, onLinkChange, onDescriptionChange, onAddTag, onRemoveTagByIndex, onSave }) { 40 | formElement.addEventListener("submit", (event) => { 41 | event.preventDefault(); // don't reload page 42 | 43 | // commit any tag left in the input 44 | this.commitTag({ onAddTag }); 45 | 46 | onSave(); 47 | }); 48 | 49 | titleInputElement.addEventListener("input", (e) => onTitleChange((e.target as HTMLInputElement).value)); 50 | linkInputElement.addEventListener("input", (e) => onLinkChange((e.target as HTMLInputElement).value)); 51 | descriptionInputElement.addEventListener("input", (e) => onDescriptionChange((e.target as HTMLInputElement).value)); 52 | addTagButtonElement.addEventListener("click", () => this.commitTag({ onAddTag, refocus: true })); 53 | tagInputElement.addEventListener("keydown", (e) => { 54 | if (e.isComposing) { 55 | // passthrough IME events 56 | return; 57 | } 58 | 59 | if (e.key === "Enter") { 60 | // prevent form submission 61 | e.preventDefault(); 62 | } 63 | 64 | if (tagInputElement.value !== "" && e.key === "Enter") { 65 | this.commitTag({ onAddTag }); 66 | } 67 | if (tagInputElement.value === "" && e.key === "Backspace") { 68 | this.tryFocusLastTag(); 69 | } 70 | }); 71 | 72 | addedTagsElement.addEventListener("click", (e) => { 73 | const selectedButton = (e.target as HTMLElement).closest("button"); 74 | if (!selectedButton) return; 75 | const removeIndex = parseInt(((e.target as HTMLElement).closest("button")!.dataset as any).index); 76 | this.removeTagAtIndex(removeIndex, onRemoveTagByIndex); 77 | }); 78 | addedTagsElement.addEventListener("keydown", (e) => { 79 | if (e.key === "Backspace" || e.key === "Delete") { 80 | const removeIndex = parseInt(((e.target as HTMLElement).closest("button")!.dataset as any).index); 81 | this.removeTagAtIndex(removeIndex, onRemoveTagByIndex); 82 | } 83 | }); 84 | previewElement.addEventListener("focus", () => previewElement.select()); 85 | previewElement.addEventListener("click", () => previewElement.select()); 86 | 87 | openOptionsButtonElement.addEventListener("click", () => chrome.runtime.openOptionsPage()); 88 | } 89 | 90 | render({ state, previousState }: { state: FullModel; previousState: FullModel }) { 91 | const { title, href, description, isSaved, tags, tagOptions, saveStatus, connectionStatus, libraryUrl } = state; 92 | 93 | existingLinkMarker.hidden = !isSaved; 94 | 95 | if (title !== previousState.title) { 96 | titleInputElement.value = title!; 97 | } 98 | 99 | if (href !== previousState.href) { 100 | linkInputElement.value = href!; 101 | } 102 | 103 | if (description !== previousState.description) { 104 | descriptionInputElement.value = description; 105 | } 106 | 107 | if (tags.join("") !== previousState.tags.join("")) { 108 | addedTagsElement.innerHTML = tags 109 | .map((tag, index) => ``) 110 | .join(""); 111 | } 112 | 113 | if (tagOptions.join("") !== previousState.tagOptions.join("")) { 114 | tagOptionsElement.innerHTML = tagOptions.map((option) => `