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 <cursoragent@cursor.com>
This commit is contained in:
Olivier Rouiller 2026-06-04 18:24:56 +02:00
commit db51a01e62
17 changed files with 1240 additions and 0 deletions

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
.vscode
node_modules
main.js
*.map
data.json
.DS_Store

86
README.md Normal file
View file

@ -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

49
esbuild.config.mjs Normal file
View file

@ -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();
}

9
manifest.json Normal file
View file

@ -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
}

625
package-lock.json generated Normal file
View file

@ -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
}
}
}

19
package.json Normal file
View file

@ -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"
}
}

14
src/constants.ts Normal file
View file

@ -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];

141
src/gdocs-view.ts Normal file
View file

@ -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<void> {
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<void> {
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<void> {
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;
};
}

48
src/main.ts Normal file
View file

@ -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<void> {
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<void> {
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<void> {
await this.saveData(this.settings);
}
getActiveExtensions(): string[] {
return this.settings.extensions.filter((ext) => ext.length > 0);
}
}

View file

@ -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<string, string> = {
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<string, string> = {
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.",
};
}

48
src/settings.ts Normal file
View file

@ -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",
});
}
}

27
styles.css Normal file
View file

@ -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;
}

View file

@ -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"}

1
test/fixtures/sample.gsheet vendored Normal file
View file

@ -0,0 +1 @@
{"url":"https://docs.google.com/spreadsheets/d/1abc123/edit","resource_id":"spreadsheet:1abc123"}

View file

@ -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");

13
test/parse-test.mjs Normal file
View file

@ -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);

19
tsconfig.json Normal file
View file

@ -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"]
}