From db51a01e627503f2b5bbf29ef1aa104b6ea62e32 Mon Sep 17 00:00:00 2001 From: Olivier Rouiller Date: Thu, 4 Jun 2026 18:24:56 +0200 Subject: [PATCH] Add GDocs Obsidian plugin for Google Drive shortcuts. Opens .gdoc, .gsheet, and other Workspace shortcut files in Obsidian's Web Viewer by parsing JSON metadata (url, resource_id, or doc_id). Co-authored-by: Cursor --- .gitignore | 6 + README.md | 86 ++++ esbuild.config.mjs | 49 ++ manifest.json | 9 + package-lock.json | 625 ++++++++++++++++++++++++ package.json | 19 + src/constants.ts | 14 + src/gdocs-view.ts | 141 ++++++ src/main.ts | 48 ++ src/parse-gdrive-shortcut.ts | 112 +++++ src/settings.ts | 48 ++ styles.css | 27 + test/fixtures/sample-drive-desktop.gdoc | 1 + test/fixtures/sample.gsheet | 1 + test/parse-gdrive-shortcut.test.mjs | 22 + test/parse-test.mjs | 13 + tsconfig.json | 19 + 17 files changed, 1240 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 esbuild.config.mjs create mode 100644 manifest.json create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 src/constants.ts create mode 100644 src/gdocs-view.ts create mode 100644 src/main.ts create mode 100644 src/parse-gdrive-shortcut.ts create mode 100644 src/settings.ts create mode 100644 styles.css create mode 100644 test/fixtures/sample-drive-desktop.gdoc create mode 100644 test/fixtures/sample.gsheet create mode 100644 test/parse-gdrive-shortcut.test.mjs create mode 100644 test/parse-test.mjs create mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2061a34 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +.vscode +node_modules +main.js +*.map +data.json +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..2efd3d8 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# GDocs + +Obsidian plugin that opens Google Drive shortcut files in Obsidian's **Web Viewer**. + +When Google Drive for Desktop (or similar sync) stores Docs, Sheets, and other Workspace files in your vault, they appear as small JSON files (`.gdoc`, `.gsheet`, etc.). GDocs registers those extensions, shows them in the file explorer with their extension, and opens the linked Google URL in a tab when you click them. + +## Requirements + +- **Obsidian 1.8+** (desktop for Web Viewer) +- **Web Viewer** core plugin enabled: Settings → Core plugins → **Web viewer** +- Google Drive shortcut files in your vault (from Drive for Desktop, rclone, Synology Drive, etc.) + +## Supported extensions + +| Extension | Google app | +|-----------|------------| +| `.gdoc` | Docs | +| `.gsheet` | Sheets | +| `.gslides` | Slides | +| `.gdraw` | Drawings | +| `.gform` | Forms | +| `.gtable` | Tables | +| `.gscript` | Apps Script | +| `.gjam` | Jamboard | + +## Installation (development) + +1. Clone this repo into your vault's `.obsidian/plugins/gdocs` folder, or symlink it there. +2. Install dependencies and build: + +```bash +npm install +npm run build +``` + +3. Enable **GDocs** under Settings → Community plugins. +4. Reload Obsidian. + +For development, run `npm run dev` to watch and rebuild. + +## Usage + +Click any `.gdoc`, `.gsheet`, or other supported shortcut in the file explorer. The plugin reads the `url` field from the JSON file and opens it in Web Viewer. + +If Web Viewer is disabled, GDocs can open the link in your system browser instead (see plugin settings). + +### Mobile + +Web Viewer is not available on mobile. GDocs shows the URL with **Copy link** and **Open in browser** buttons. + +## Shortcut file format + +Shortcut files are JSON. Google Drive for Desktop typically uses: + +```json +{ + "doc_id": "1VxFROKm0zoJV2iH678wO94hZ0PY9fN_gAGOxhZacc8w", + "email": "you@example.com" +} +``` + +GDocs builds the Docs/Sheets URL from `doc_id` and the file extension (`.gdoc`, `.gsheet`, etc.). + +Older or third-party sync tools may use: + +```json +{ + "url": "https://docs.google.com/spreadsheets/d/…/edit", + "resource_id": "spreadsheet:…" +} +``` + +## Troubleshooting + +- **Files not visible in the explorer** + Check Settings → Files & links → **Detect all file extensions**, and ensure `.gdoc` / `.gsheet` are not listed under **Excluded files**. + +- **Opens in browser instead of Obsidian** + Enable the Web Viewer core plugin, or turn on “Open in system browser when Web Viewer is off” in GDocs settings. + +- **Warning about moving shortcut files** + Do not move `.gdoc` / `.gsheet` files out of a syncing Google Drive folder; Google may delete the online document. + +## License + +MIT diff --git a/esbuild.config.mjs b/esbuild.config.mjs new file mode 100644 index 0000000..4f37b16 --- /dev/null +++ b/esbuild.config.mjs @@ -0,0 +1,49 @@ +import esbuild from "esbuild"; +import process from "process"; +import { builtinModules } from "node:module"; + +const banner = `/* +THIS IS A GENERATED/BUNDLED FILE BY ESBUILD +if you want to view the source, please visit the github repository of this plugin +*/ +`; + +const prod = process.argv[2] === "production"; + +const context = await esbuild.context({ + banner: { + js: banner, + }, + entryPoints: ["src/main.ts"], + bundle: true, + external: [ + "obsidian", + "electron", + "@codemirror/autocomplete", + "@codemirror/collab", + "@codemirror/commands", + "@codemirror/language", + "@codemirror/lint", + "@codemirror/search", + "@codemirror/state", + "@codemirror/view", + "@lezer/common", + "@lezer/highlight", + "@lezer/lr", + ...builtinModules, + ], + format: "cjs", + target: "es2021", + logLevel: "info", + sourcemap: prod ? false : "inline", + treeShaking: true, + outfile: "main.js", + minify: prod, +}); + +if (prod) { + await context.rebuild(); + process.exit(0); +} else { + await context.watch(); +} diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..2844257 --- /dev/null +++ b/manifest.json @@ -0,0 +1,9 @@ +{ + "id": "gdocs", + "name": "GDocs", + "version": "1.0.0", + "minAppVersion": "1.8.0", + "description": "Open Google Drive shortcut files (.gdoc, .gsheet, etc.) in Obsidian's Web Viewer.", + "author": "Olivier", + "isDesktopOnly": false +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..39366d3 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,625 @@ +{ + "name": "obsidian-gdocs", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "obsidian-gdocs", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@types/node": "^22.15.17", + "esbuild": "0.25.5", + "obsidian": "latest", + "typescript": "^5.8.3" + } + }, + "node_modules/@codemirror/state": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.0.tgz", + "integrity": "sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.38.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.6.tgz", + "integrity": "sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@codemirror/state": "^6.5.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz", + "integrity": "sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.5.tgz", + "integrity": "sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz", + "integrity": "sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.5.tgz", + "integrity": "sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", + "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz", + "integrity": "sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz", + "integrity": "sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz", + "integrity": "sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz", + "integrity": "sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz", + "integrity": "sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz", + "integrity": "sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz", + "integrity": "sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz", + "integrity": "sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz", + "integrity": "sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz", + "integrity": "sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz", + "integrity": "sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz", + "integrity": "sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz", + "integrity": "sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz", + "integrity": "sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz", + "integrity": "sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz", + "integrity": "sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz", + "integrity": "sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz", + "integrity": "sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz", + "integrity": "sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz", + "integrity": "sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/codemirror": { + "version": "5.60.8", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.8.tgz", + "integrity": "sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/tern": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/tern": { + "version": "0.23.9", + "resolved": "https://registry.npmjs.org/@types/tern/-/tern-0.23.9.tgz", + "integrity": "sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/esbuild": { + "version": "0.25.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", + "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.5", + "@esbuild/android-arm": "0.25.5", + "@esbuild/android-arm64": "0.25.5", + "@esbuild/android-x64": "0.25.5", + "@esbuild/darwin-arm64": "0.25.5", + "@esbuild/darwin-x64": "0.25.5", + "@esbuild/freebsd-arm64": "0.25.5", + "@esbuild/freebsd-x64": "0.25.5", + "@esbuild/linux-arm": "0.25.5", + "@esbuild/linux-arm64": "0.25.5", + "@esbuild/linux-ia32": "0.25.5", + "@esbuild/linux-loong64": "0.25.5", + "@esbuild/linux-mips64el": "0.25.5", + "@esbuild/linux-ppc64": "0.25.5", + "@esbuild/linux-riscv64": "0.25.5", + "@esbuild/linux-s390x": "0.25.5", + "@esbuild/linux-x64": "0.25.5", + "@esbuild/netbsd-arm64": "0.25.5", + "@esbuild/netbsd-x64": "0.25.5", + "@esbuild/openbsd-arm64": "0.25.5", + "@esbuild/openbsd-x64": "0.25.5", + "@esbuild/sunos-x64": "0.25.5", + "@esbuild/win32-arm64": "0.25.5", + "@esbuild/win32-ia32": "0.25.5", + "@esbuild/win32-x64": "0.25.5" + } + }, + "node_modules/moment": { + "version": "2.29.4", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", + "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/obsidian": { + "version": "1.13.0", + "resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.13.0.tgz", + "integrity": "sha512-PHw5+SAPlJ0S3leFvJ0wgFg63Z3DavxL6+d1ll+8toXR2ZlYKc1rMWqdUv9LgUbTwPQUyY6yfhOMMivampRRiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/codemirror": "5.60.8", + "moment": "2.29.4" + }, + "peerDependencies": { + "@codemirror/state": "6.5.0", + "@codemirror/view": "6.38.6" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "dev": true, + "license": "MIT", + "peer": true + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..fb5b3be --- /dev/null +++ b/package.json @@ -0,0 +1,19 @@ +{ + "name": "obsidian-gdocs", + "version": "1.0.0", + "description": "Open Google Drive shortcut files in Obsidian's Web Viewer", + "main": "main.js", + "type": "module", + "scripts": { + "dev": "node esbuild.config.mjs", + "build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production" + }, + "keywords": [], + "license": "MIT", + "devDependencies": { + "@types/node": "^22.15.17", + "esbuild": "0.25.5", + "obsidian": "latest", + "typescript": "^5.8.3" + } +} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..f978693 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,14 @@ +export const VIEW_TYPE_GDOCS = "gdocs-view"; + +export const DEFAULT_GDRIVE_EXTENSIONS = [ + "gdoc", + "gsheet", + "gslides", + "gdraw", + "gform", + "gtable", + "gscript", + "gjam", +] as const; + +export type GdriveExtension = (typeof DEFAULT_GDRIVE_EXTENSIONS)[number]; diff --git a/src/gdocs-view.ts b/src/gdocs-view.ts new file mode 100644 index 0000000..ec88d9d --- /dev/null +++ b/src/gdocs-view.ts @@ -0,0 +1,141 @@ +import { + FileView, + Notice, + Platform, + WorkspaceLeaf, + type TFile, +} from "obsidian"; +import type GDocsPlugin from "./main"; +import { VIEW_TYPE_GDOCS } from "./constants"; +import { parseGdriveShortcut } from "./parse-gdrive-shortcut"; + +export class GDocsView extends FileView { + plugin: GDocsPlugin; + + constructor(leaf: WorkspaceLeaf, plugin: GDocsPlugin) { + super(leaf); + this.plugin = plugin; + } + + getViewType(): string { + return VIEW_TYPE_GDOCS; + } + + getDisplayText(): string { + return this.file?.basename ?? "Google Drive"; + } + + canAcceptExtension(extension: string): boolean { + return this.plugin.settings.extensions.includes(extension.toLowerCase()); + } + + async onLoadFile(file: TFile): Promise { + this.clearError(); + let raw: string; + try { + raw = await this.app.vault.read(file); + } catch { + this.showError("Could not read file.", null); + return; + } + + const parsed = parseGdriveShortcut(raw, file.extension); + if (!parsed.ok) { + this.showError(parsed.error, null); + return; + } + + await this.openUrl(parsed.url); + return; + } + + async onUnloadFile(_file: TFile): Promise { + this.clearError(); + } + + private clearError(): void { + this.contentEl.empty(); + } + + private showError(message: string, url: string | null): void { + this.clearError(); + const wrap = this.contentEl.createDiv({ cls: "gdocs-error" }); + wrap.createDiv({ cls: "gdocs-error-title", text: "Could not open Google shortcut" }); + wrap.createDiv({ cls: "gdocs-error-detail", text: message }); + if (url) { + wrap.createDiv({ cls: "gdocs-error-url", text: url }); + } + wrap.createEl("p", { + text: "You can open the shortcut file as plain text from the file menu to inspect its contents.", + }); + } + + private async openUrl(url: string): Promise { + if (Platform.isMobile) { + this.showMobileFallback(url); + return; + } + + if (!this.isWebViewerEnabled()) { + if (this.plugin.settings.openInBrowserIfNoWebViewer) { + window.open(url, "_blank"); + return; + } + this.showError( + "Web Viewer is disabled. Enable it under Settings → Core plugins → Web viewer, or turn on “Open in system browser when Web Viewer is off” in GDocs settings.", + url, + ); + return; + } + + try { + await this.leaf.setViewState({ + type: "webviewer", + state: { url, navigate: true }, + active: true, + }); + } catch { + if (this.plugin.settings.openInBrowserIfNoWebViewer) { + window.open(url, "_blank"); + } else { + this.showError("Failed to open Web Viewer.", url); + } + } + } + + private showMobileFallback(url: string): void { + this.clearError(); + const wrap = this.contentEl.createDiv({ cls: "gdocs-error" }); + wrap.createDiv({ + cls: "gdocs-error-title", + text: "Web Viewer is not available on mobile", + }); + wrap.createDiv({ + cls: "gdocs-error-detail", + text: "Copy the link below and open it in your browser.", + }); + wrap.createDiv({ cls: "gdocs-error-url", text: url }); + + const actions = wrap.createDiv({ cls: "gdocs-mobile-actions" }); + actions.createEl("button", { text: "Copy link" }).addEventListener("click", () => { + void navigator.clipboard.writeText(url); + new Notice("Link copied to clipboard"); + }); + actions.createEl("button", { text: "Open in browser" }).addEventListener("click", () => { + window.open(url, "_blank"); + }); + } + + private isWebViewerEnabled(): boolean { + const internal = ( + this.app as AppWithInternalPlugins + ).internalPlugins?.getPluginById("web-viewer"); + return internal?.enabled === true; + } +} + +interface AppWithInternalPlugins { + internalPlugins?: { + getPluginById(id: string): { enabled: boolean } | undefined; + }; +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..184938c --- /dev/null +++ b/src/main.ts @@ -0,0 +1,48 @@ +import { Plugin } from "obsidian"; +import { DEFAULT_GDRIVE_EXTENSIONS, VIEW_TYPE_GDOCS } from "./constants"; +import { GDocsView } from "./gdocs-view"; +import { + DEFAULT_SETTINGS, + GDocsSettingTab, + type GDocsSettings, +} from "./settings"; + +export default class GDocsPlugin extends Plugin { + settings: GDocsSettings = DEFAULT_SETTINGS; + + async onload(): Promise { + await this.loadSettings(); + + this.registerView( + VIEW_TYPE_GDOCS, + (leaf) => new GDocsView(leaf, this), + ); + + const extensions = this.getActiveExtensions(); + if (extensions.length > 0) { + this.registerExtensions(extensions, VIEW_TYPE_GDOCS); + } + + this.addSettingTab(new GDocsSettingTab(this.app, this)); + } + + onunload(): void { + this.app.workspace.detachLeavesOfType(VIEW_TYPE_GDOCS); + } + + async loadSettings(): Promise { + const loaded = await this.loadData(); + this.settings = { ...DEFAULT_SETTINGS, ...loaded }; + if (!Array.isArray(this.settings.extensions)) { + this.settings.extensions = [...DEFAULT_GDRIVE_EXTENSIONS]; + } + } + + async saveSettings(): Promise { + await this.saveData(this.settings); + } + + getActiveExtensions(): string[] { + return this.settings.extensions.filter((ext) => ext.length > 0); + } +} diff --git a/src/parse-gdrive-shortcut.ts b/src/parse-gdrive-shortcut.ts new file mode 100644 index 0000000..d79bcb2 --- /dev/null +++ b/src/parse-gdrive-shortcut.ts @@ -0,0 +1,112 @@ +export type ParseResult = + | { ok: true; url: string } + | { ok: false; error: string }; + +interface GdriveShortcutJson { + url?: string; + resource_id?: string; + doc_id?: string; +} + +/** Google Drive for Desktop format (doc_id + file extension). */ +const EXTENSION_URL_TEMPLATES: Record = { + gdoc: "https://docs.google.com/document/d/{id}/edit", + gsheet: "https://docs.google.com/spreadsheets/d/{id}/edit", + gslides: "https://docs.google.com/presentation/d/{id}/edit", + gdraw: "https://docs.google.com/drawings/d/{id}/edit", + gform: "https://docs.google.com/forms/d/{id}/viewform", + gtable: "https://docs.google.com/spreadsheets/d/{id}/edit", + gscript: "https://script.google.com/home/projects/{id}/edit", + gjam: "https://jamboard.google.com/d/{id}", +}; + +const RESOURCE_URL_TEMPLATES: Record = { + document: "https://docs.google.com/document/d/{id}/edit", + spreadsheet: "https://docs.google.com/spreadsheets/d/{id}/edit", + presentation: "https://docs.google.com/presentation/d/{id}/edit", + drawing: "https://docs.google.com/drawings/d/{id}/edit", + form: "https://docs.google.com/forms/d/{id}/viewform", + freebird: "https://jamboard.google.com/d/{id}", + jam: "https://jamboard.google.com/d/{id}", + script: "https://script.google.com/d/{id}/edit", + table: "https://docs.google.com/spreadsheets/d/{id}/edit", +}; + +function normalizeUrl(url: string): string | null { + const trimmed = url.trim(); + if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) { + return trimmed; + } + return null; +} + +function urlFromResourceId(resourceId: string): string | null { + const colon = resourceId.indexOf(":"); + if (colon <= 0) { + return null; + } + const kind = resourceId.slice(0, colon).toLowerCase(); + const id = resourceId.slice(colon + 1).trim(); + if (!id) { + return null; + } + const template = RESOURCE_URL_TEMPLATES[kind]; + if (template) { + return template.replace("{id}", id); + } + return `https://drive.google.com/open?id=${encodeURIComponent(id)}`; +} + +function urlFromDocId(docId: string, extension?: string): string | null { + const id = docId.trim(); + if (!id) { + return null; + } + if (extension) { + const template = EXTENSION_URL_TEMPLATES[extension.toLowerCase()]; + if (template) { + return template.replace("{id}", id); + } + } + return `https://drive.google.com/open?id=${encodeURIComponent(id)}`; +} + +export function parseGdriveShortcut(raw: string, extension?: string): ParseResult { + const trimmed = raw.replace(/^\uFEFF/, "").trim(); + if (!trimmed) { + return { ok: false, error: "File is empty." }; + } + + let data: GdriveShortcutJson; + try { + data = JSON.parse(trimmed) as GdriveShortcutJson; + } catch { + return { ok: false, error: "Invalid JSON. Expected a Google Drive shortcut file." }; + } + + if (typeof data.url === "string") { + const url = normalizeUrl(data.url); + if (url) { + return { ok: true, url }; + } + } + + if (typeof data.resource_id === "string") { + const url = urlFromResourceId(data.resource_id); + if (url) { + return { ok: true, url }; + } + } + + if (typeof data.doc_id === "string") { + const url = urlFromDocId(data.doc_id, extension); + if (url) { + return { ok: true, url }; + } + } + + return { + ok: false, + error: "No valid url, doc_id, or resource_id found in shortcut file.", + }; +} diff --git a/src/settings.ts b/src/settings.ts new file mode 100644 index 0000000..65cfcfd --- /dev/null +++ b/src/settings.ts @@ -0,0 +1,48 @@ +import { App, PluginSettingTab, Setting } from "obsidian"; +import type GDocsPlugin from "./main"; +import { DEFAULT_GDRIVE_EXTENSIONS } from "./constants"; + +export interface GDocsSettings { + openInBrowserIfNoWebViewer: boolean; + extensions: string[]; +} + +export const DEFAULT_SETTINGS: GDocsSettings = { + openInBrowserIfNoWebViewer: true, + extensions: [...DEFAULT_GDRIVE_EXTENSIONS], +}; + +export class GDocsSettingTab extends PluginSettingTab { + plugin: GDocsPlugin; + + constructor(app: App, plugin: GDocsPlugin) { + super(app, plugin); + this.plugin = plugin; + } + + display(): void { + const { containerEl } = this; + containerEl.empty(); + + containerEl.createEl("h2", { text: "GDocs settings" }); + + new Setting(containerEl) + .setName("Open in system browser when Web Viewer is off") + .setDesc( + "If the Web Viewer core plugin is disabled, open the Google document in your default browser instead of showing an error.", + ) + .addToggle((toggle) => + toggle + .setValue(this.plugin.settings.openInBrowserIfNoWebViewer) + .onChange(async (value) => { + this.plugin.settings.openInBrowserIfNoWebViewer = value; + await this.plugin.saveSettings(); + }), + ); + + containerEl.createEl("p", { + text: `Supported extensions: ${DEFAULT_GDRIVE_EXTENSIONS.map((e) => "." + e).join(", ")}`, + cls: "setting-item-description", + }); + } +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..23eb5d8 --- /dev/null +++ b/styles.css @@ -0,0 +1,27 @@ +.gdocs-error { + padding: 1.5rem; + max-width: 36rem; +} + +.gdocs-error-title { + font-weight: 600; + margin-bottom: 0.5rem; +} + +.gdocs-error-detail { + color: var(--text-muted); + margin-bottom: 1rem; + word-break: break-word; +} + +.gdocs-error-url { + font-size: 0.85em; + word-break: break-all; + margin-bottom: 1rem; +} + +.gdocs-mobile-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} diff --git a/test/fixtures/sample-drive-desktop.gdoc b/test/fixtures/sample-drive-desktop.gdoc new file mode 100644 index 0000000..35a2c53 --- /dev/null +++ b/test/fixtures/sample-drive-desktop.gdoc @@ -0,0 +1 @@ +{"":"WARNING! DO NOT EDIT THIS FILE! ANY CHANGES MADE WILL BE LOST!","doc_id":"1VxFROKm0zoJV2iH678wO94hZ0PY9fN_gAGOxhZacc8w","resource_key":"","email":"o.rouiller@gmail.com"} diff --git a/test/fixtures/sample.gsheet b/test/fixtures/sample.gsheet new file mode 100644 index 0000000..4e162a2 --- /dev/null +++ b/test/fixtures/sample.gsheet @@ -0,0 +1 @@ +{"url":"https://docs.google.com/spreadsheets/d/1abc123/edit","resource_id":"spreadsheet:1abc123"} diff --git a/test/parse-gdrive-shortcut.test.mjs b/test/parse-gdrive-shortcut.test.mjs new file mode 100644 index 0000000..bc90e92 --- /dev/null +++ b/test/parse-gdrive-shortcut.test.mjs @@ -0,0 +1,22 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +// Inline parser checks (mirrors src/parse-gdrive-shortcut.ts logic for CI-less smoke test) +function parse(raw) { + const trimmed = raw.replace(/^\uFEFF/, "").trim(); + const data = JSON.parse(trimmed); + if (typeof data.url === "string" && /^https?:\/\//.test(data.url.trim())) { + return { ok: true, url: data.url.trim() }; + } + return { ok: false }; +} + +const dir = dirname(fileURLToPath(import.meta.url)); +const fixture = readFileSync(join(dir, "fixtures", "sample.gsheet"), "utf8"); +const r = parse(fixture); +if (!r.ok) { + console.error("parse failed"); + process.exit(1); +} +console.log("parse-gdrive-shortcut smoke test passed"); diff --git a/test/parse-test.mjs b/test/parse-test.mjs new file mode 100644 index 0000000..00e97a1 --- /dev/null +++ b/test/parse-test.mjs @@ -0,0 +1,13 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +// Minimal inline test (parser is bundled for Obsidian; duplicate logic check via build + manual fixtures) +const fixturesDir = join(dirname(fileURLToPath(import.meta.url)), "fixtures"); +const sample = readFileSync(join(fixturesDir, "sample.gsheet"), "utf8"); +const data = JSON.parse(sample); +if (!data.url?.startsWith("https://")) { + console.error("Fixture invalid"); + process.exit(1); +} +console.log("Fixture OK:", data.url); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..3f35db2 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "inlineSourceMap": true, + "inlineSources": true, + "module": "ESNext", + "target": "ES2021", + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + "moduleResolution": "node", + "isolatedModules": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowSyntheticDefaultImports": true, + "lib": ["ES2021", "DOM"] + }, + "include": ["src/**/*.ts"] +}