mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.53.20: stickToListBottom short-circuits the geometric prevAtBottom check
This commit is contained in:
parent
84e61d24bf
commit
dcb8179401
27 changed files with 12939 additions and 40 deletions
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -1 +1,5 @@
|
|||
.DS_Store
|
||||
node_modules/
|
||||
data.json
|
||||
.deploy-target
|
||||
*.log
|
||||
|
|
|
|||
23
esbuild.config.mjs
Normal file
23
esbuild.config.mjs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import esbuild from "esbuild";
|
||||
|
||||
const prod = process.argv[2] === "production";
|
||||
|
||||
const ctx = await esbuild.context({
|
||||
entryPoints: ["src/main.ts"],
|
||||
bundle: true,
|
||||
external: ["obsidian", "electron", "@codemirror/*", "@lezer/*"],
|
||||
format: "cjs",
|
||||
target: "es2020",
|
||||
logLevel: "info",
|
||||
sourcemap: prod ? false : "inline",
|
||||
treeShaking: true,
|
||||
minify: prod,
|
||||
outfile: "main.js",
|
||||
});
|
||||
|
||||
if (prod) {
|
||||
await ctx.rebuild();
|
||||
process.exit(0);
|
||||
} else {
|
||||
await ctx.watch();
|
||||
}
|
||||
74
main.js
74
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.52.0",
|
||||
"version": "0.53.20",
|
||||
"minAppVersion": "1.7.0",
|
||||
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
|
||||
"author": "Human",
|
||||
|
|
|
|||
686
package-lock.json
generated
Normal file
686
package-lock.json
generated
Normal file
|
|
@ -0,0 +1,686 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.4.1",
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"esbuild": "^0.21.0",
|
||||
"obsidian": "latest",
|
||||
"typescript": "^5.4.0"
|
||||
}
|
||||
},
|
||||
"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.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
|
||||
"integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
|
||||
"integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
|
||||
"integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
|
||||
"integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
|
||||
"integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
|
||||
"integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
|
||||
"integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
|
||||
"integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
|
||||
"integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.39",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.39.tgz",
|
||||
"integrity": "sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==",
|
||||
"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/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
"integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.21.5",
|
||||
"@esbuild/android-arm": "0.21.5",
|
||||
"@esbuild/android-arm64": "0.21.5",
|
||||
"@esbuild/android-x64": "0.21.5",
|
||||
"@esbuild/darwin-arm64": "0.21.5",
|
||||
"@esbuild/darwin-x64": "0.21.5",
|
||||
"@esbuild/freebsd-arm64": "0.21.5",
|
||||
"@esbuild/freebsd-x64": "0.21.5",
|
||||
"@esbuild/linux-arm": "0.21.5",
|
||||
"@esbuild/linux-arm64": "0.21.5",
|
||||
"@esbuild/linux-ia32": "0.21.5",
|
||||
"@esbuild/linux-loong64": "0.21.5",
|
||||
"@esbuild/linux-mips64el": "0.21.5",
|
||||
"@esbuild/linux-ppc64": "0.21.5",
|
||||
"@esbuild/linux-riscv64": "0.21.5",
|
||||
"@esbuild/linux-s390x": "0.21.5",
|
||||
"@esbuild/linux-x64": "0.21.5",
|
||||
"@esbuild/netbsd-x64": "0.21.5",
|
||||
"@esbuild/openbsd-x64": "0.21.5",
|
||||
"@esbuild/sunos-x64": "0.21.5",
|
||||
"@esbuild/win32-arm64": "0.21.5",
|
||||
"@esbuild/win32-ia32": "0.21.5",
|
||||
"@esbuild/win32-x64": "0.21.5"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"license": "(MIT OR GPL-3.0-or-later)",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.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.12.3",
|
||||
"resolved": "https://registry.npmjs.org/obsidian/-/obsidian-1.12.3.tgz",
|
||||
"integrity": "sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==",
|
||||
"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/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
package.json
Normal file
20
package.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.53.20",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
"build": "node esbuild.config.mjs production",
|
||||
"deploy:files": "node scripts/deploy.mjs",
|
||||
"deploy": "npm run build && node scripts/deploy.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.10.0",
|
||||
"esbuild": "^0.21.0",
|
||||
"obsidian": "latest",
|
||||
"typescript": "^5.4.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"jszip": "^3.10.1"
|
||||
}
|
||||
}
|
||||
79
scripts/deploy.mjs
Normal file
79
scripts/deploy.mjs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
#!/usr/bin/env node
|
||||
// Copy the three plugin artifacts (main.js, manifest.json, styles.css)
|
||||
// into the vault's plugin folder. Replaces a symlink-based setup that
|
||||
// Obsidian Sync was indexing slowly.
|
||||
//
|
||||
// Configure the destination once:
|
||||
// - Either set STASHPAD_DEPLOY in your environment.
|
||||
// - Or create a `.deploy-target` file at the project root containing
|
||||
// the absolute path of the vault plugin folder, e.g.:
|
||||
// /Users/you/Vault/.obsidian/plugins/stashpad
|
||||
// The file is gitignored.
|
||||
//
|
||||
// Usage:
|
||||
// npm run deploy — build + copy
|
||||
// npm run deploy:files — copy only (no build)
|
||||
|
||||
import { existsSync, readFileSync, copyFileSync, mkdirSync, statSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Use fileURLToPath so spaces (and other URL-encoded chars) in the
|
||||
// project path don't break the resolved filesystem path.
|
||||
const ROOT = resolve(fileURLToPath(new URL("..", import.meta.url)));
|
||||
const ARTIFACTS = ["main.js", "manifest.json", "styles.css"];
|
||||
|
||||
function resolveTarget() {
|
||||
const envTarget = process.env.STASHPAD_DEPLOY?.trim();
|
||||
if (envTarget) return resolve(envTarget);
|
||||
const cfgPath = join(ROOT, ".deploy-target");
|
||||
if (existsSync(cfgPath)) {
|
||||
const raw = readFileSync(cfgPath, "utf8").trim();
|
||||
if (raw) return resolve(raw);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`\n[deploy] ${msg}\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const target = resolveTarget();
|
||||
if (!target) {
|
||||
fail(
|
||||
"No deploy target configured. Set STASHPAD_DEPLOY env var or create a\n" +
|
||||
".deploy-target file at the project root with the destination path.\n" +
|
||||
"Example: /Users/you/MyVault/.obsidian/plugins/stashpad",
|
||||
);
|
||||
}
|
||||
|
||||
// Sanity-check: refuse to write to a path that doesn't look like a
|
||||
// plugin folder (avoids accidental misconfigurations clobbering things).
|
||||
const targetParent = dirname(target);
|
||||
if (!existsSync(target)) {
|
||||
try {
|
||||
mkdirSync(target, { recursive: true });
|
||||
} catch (e) {
|
||||
fail(`Couldn't create destination folder: ${target}\n${e.message}`);
|
||||
}
|
||||
}
|
||||
if (!existsSync(targetParent)) {
|
||||
fail(`Parent of destination doesn't exist: ${targetParent}`);
|
||||
}
|
||||
|
||||
let copied = 0;
|
||||
let missing = [];
|
||||
for (const name of ARTIFACTS) {
|
||||
const src = join(ROOT, name);
|
||||
if (!existsSync(src)) { missing.push(name); continue; }
|
||||
const dst = join(target, name);
|
||||
copyFileSync(src, dst);
|
||||
const sz = statSync(dst).size;
|
||||
console.log(`[deploy] ${name.padEnd(14)} → ${dst} (${sz} bytes)`);
|
||||
copied++;
|
||||
}
|
||||
if (missing.length) {
|
||||
console.warn(`[deploy] WARNING: missing artifacts: ${missing.join(", ")} — did you build?`);
|
||||
}
|
||||
console.log(`[deploy] copied ${copied}/${ARTIFACTS.length} → ${target}`);
|
||||
19
src/active-view.ts
Normal file
19
src/active-view.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
let active: any = null;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function setActiveView(v: any): void {
|
||||
if (active === v) return;
|
||||
active = v;
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
export function getActiveView(): any { return active; }
|
||||
export function clearActiveView(v: any): void {
|
||||
if (active === v) {
|
||||
active = null;
|
||||
for (const fn of listeners) fn();
|
||||
}
|
||||
}
|
||||
export function onActiveViewChange(fn: () => void): () => void {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
359
src/composer-autocomplete.ts
Normal file
359
src/composer-autocomplete.ts
Normal file
|
|
@ -0,0 +1,359 @@
|
|||
import { App, Scope, TFile } from "obsidian";
|
||||
|
||||
/**
|
||||
* Composer autocomplete: a lightweight popup attached to a plain
|
||||
* <textarea> that suggests tags (after `#`) and wikilink targets
|
||||
* (after `[[`). Built for Stashpad's composer because Obsidian's
|
||||
* EditorSuggest API only works against CodeMirror editors.
|
||||
*
|
||||
* Lifecycle:
|
||||
* const ac = new ComposerAutocomplete(app, textareaEl);
|
||||
* ac.attach(); // start listening
|
||||
* ac.detach(); // stop and remove popup
|
||||
*
|
||||
* Triggers (matched against the substring ending at the caret):
|
||||
* #foo → tag suggestions ("#foo", "#foobar", ...)
|
||||
* [[foo → file suggestions (basenames containing "foo")
|
||||
*
|
||||
* Keyboard while popup is open:
|
||||
* ↑/↓ move highlighted item
|
||||
* Enter / Tab insert highlighted item
|
||||
* Escape dismiss without inserting
|
||||
*
|
||||
* The popup self-positions just below the textarea and follows scroll/
|
||||
* resize. It does NOT try to anchor to the caret position (which would
|
||||
* require a hidden mirror element); textarea-bottom anchoring is good
|
||||
* enough for a small composer.
|
||||
*/
|
||||
export class ComposerAutocomplete {
|
||||
private popupEl: HTMLDivElement | null = null;
|
||||
private items: SuggestItem[] = [];
|
||||
private activeIdx = 0;
|
||||
private state: AutocompleteState | null = null;
|
||||
|
||||
/** Cached lowercased basenames + tag list, refreshed when the vault
|
||||
* fires create/delete/rename. Avoids re-walking getMarkdownFiles()
|
||||
* on every keystroke. */
|
||||
private fileIndex: { basename: string; lower: string; file: TFile }[] = [];
|
||||
private tagIndex: string[] = [];
|
||||
private indexBuilt = false;
|
||||
private vaultListeners: Array<() => void> = [];
|
||||
/** Obsidian Scope pushed onto the keymap while the popup is open. It
|
||||
* consumes Escape (and Enter/Tab/Arrow keys are also re-bound here
|
||||
* belt-and-suspenders) so the workspace's "Escape returns to last
|
||||
* leaf" handler doesn't fire and yank focus to a previous tab. */
|
||||
private scope: Scope | null = null;
|
||||
|
||||
constructor(private app: App, private ta: HTMLTextAreaElement) {}
|
||||
|
||||
attach(): void {
|
||||
this.ta.addEventListener("input", this.onInput);
|
||||
this.ta.addEventListener("keydown", this.onKeyDown, true);
|
||||
this.ta.addEventListener("blur", this.onBlur);
|
||||
// Document-capture Escape interceptor — only acts while a popup is
|
||||
// open. Without this, Obsidian's workspace-level Escape (which
|
||||
// refocuses another tab / split) wins the capture-phase race against
|
||||
// our textarea-level listener and the user gets thrown off the view.
|
||||
const doc = this.ta.ownerDocument ?? document;
|
||||
doc.addEventListener("keydown", this.onDocEscape, true);
|
||||
this.vaultListeners.push(() => doc.removeEventListener("keydown", this.onDocEscape, true));
|
||||
this.buildIndex();
|
||||
// Refresh index on vault structure changes. Coalesce by just
|
||||
// invalidating; next openFor call rebuilds lazily.
|
||||
const invalidate = () => { this.indexBuilt = false; };
|
||||
const v = this.app.vault as any;
|
||||
v.on("create", invalidate);
|
||||
v.on("delete", invalidate);
|
||||
v.on("rename", invalidate);
|
||||
this.vaultListeners.push(
|
||||
() => v.off("create", invalidate),
|
||||
() => v.off("delete", invalidate),
|
||||
() => v.off("rename", invalidate),
|
||||
);
|
||||
}
|
||||
|
||||
detach(): void {
|
||||
this.close();
|
||||
this.ta.removeEventListener("input", this.onInput);
|
||||
this.ta.removeEventListener("keydown", this.onKeyDown, true);
|
||||
this.ta.removeEventListener("blur", this.onBlur);
|
||||
for (const off of this.vaultListeners) off();
|
||||
this.vaultListeners = [];
|
||||
}
|
||||
|
||||
// ---------- Index build ----------
|
||||
|
||||
private buildIndex(): void {
|
||||
if (this.indexBuilt) return;
|
||||
this.fileIndex = this.app.vault.getMarkdownFiles().map((f) => ({
|
||||
basename: f.basename,
|
||||
lower: f.basename.toLowerCase(),
|
||||
file: f,
|
||||
}));
|
||||
const tagsRecord = (this.app.metadataCache as any).getTags?.() ?? {};
|
||||
this.tagIndex = Object.keys(tagsRecord).sort((a, b) =>
|
||||
(tagsRecord[b] || 0) - (tagsRecord[a] || 0)
|
||||
);
|
||||
this.indexBuilt = true;
|
||||
}
|
||||
|
||||
// ---------- Trigger detection ----------
|
||||
|
||||
/** Inspect the substring ending at the caret. Return the active
|
||||
* trigger, or null if no popup should be open. */
|
||||
private detectTrigger(): AutocompleteState | null {
|
||||
const value = this.ta.value;
|
||||
const caret = this.ta.selectionStart;
|
||||
if (caret == null) return null;
|
||||
const before = value.slice(0, caret);
|
||||
|
||||
// Wikilink: [[ followed by query (no closing ]] yet, no newline)
|
||||
const linkMatch = before.match(/\[\[([^\]\[\n]*)$/);
|
||||
if (linkMatch) {
|
||||
const query = linkMatch[1];
|
||||
return {
|
||||
kind: "link",
|
||||
query,
|
||||
replaceStart: caret - query.length - 2,
|
||||
replaceEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
// Tag: # followed by tag chars, preceded by start-of-line/whitespace.
|
||||
// Require at least one character after the # — opening the popup the
|
||||
// moment the bare # is typed flooded it with the entire tag list and
|
||||
// (mysteriously) seemed to coincide with the textarea losing focus.
|
||||
const tagMatch = before.match(/(^|\s)#([A-Za-z0-9_/\-]+)$/);
|
||||
if (tagMatch) {
|
||||
const query = tagMatch[2];
|
||||
return {
|
||||
kind: "tag",
|
||||
query,
|
||||
replaceStart: caret - query.length - 1, // include the `#`
|
||||
replaceEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ---------- Suggest generation ----------
|
||||
|
||||
private buildItems(state: AutocompleteState): SuggestItem[] {
|
||||
this.buildIndex();
|
||||
const q = state.query.toLowerCase().trim();
|
||||
// All-tokens-match: split the query on whitespace; every token must
|
||||
// appear (anywhere, in any order) in the candidate. So "B and A"
|
||||
// matches a file titled "A and B". Empty query returns everything.
|
||||
const tokens = q ? q.split(/\s+/).filter(Boolean) : [];
|
||||
const matchesAll = (haystack: string): boolean => {
|
||||
if (!tokens.length) return true;
|
||||
for (const t of tokens) if (!haystack.includes(t)) return false;
|
||||
return true;
|
||||
};
|
||||
if (state.kind === "link") {
|
||||
const matches = this.fileIndex
|
||||
.filter((f) => matchesAll(f.lower))
|
||||
.slice(0, 30)
|
||||
.map((f) => ({
|
||||
label: f.basename,
|
||||
// Include the opening "[[" — replaceStart points BEFORE the
|
||||
// brackets, so we re-emit them along with the basename and
|
||||
// closing brackets to produce a complete wikilink.
|
||||
insert: `[[${f.basename}]]`,
|
||||
subtitle: f.file.path,
|
||||
}));
|
||||
return matches;
|
||||
} else {
|
||||
// Tag autocomplete: same all-tokens rule, just against the
|
||||
// pre-sorted (by usage count) tag list.
|
||||
const matches = this.tagIndex
|
||||
.filter((t) => matchesAll(t.toLowerCase()))
|
||||
.slice(0, 30)
|
||||
.map((t) => ({
|
||||
label: t,
|
||||
insert: t,
|
||||
subtitle: "",
|
||||
}));
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- Event handlers ----------
|
||||
|
||||
private onInput = (): void => {
|
||||
const state = this.detectTrigger();
|
||||
if (!state) { this.close(); return; }
|
||||
this.openFor(state);
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
// Defer slightly so a click on a popup item still registers before
|
||||
// the popup is removed by blur.
|
||||
setTimeout(() => this.close(), 120);
|
||||
};
|
||||
|
||||
private onDocEscape = (e: KeyboardEvent): void => {
|
||||
if (e.key !== "Escape") return;
|
||||
if (!this.state || !this.items.length) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
this.close();
|
||||
};
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent): void => {
|
||||
if (!this.state || !this.items.length) return;
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.activeIdx = (this.activeIdx + 1) % this.items.length;
|
||||
this.refreshActive();
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.activeIdx = (this.activeIdx - 1 + this.items.length) % this.items.length;
|
||||
this.refreshActive();
|
||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
this.commit();
|
||||
} else if (e.key === "Escape") {
|
||||
// stopImmediatePropagation beats Obsidian's workspace-level
|
||||
// Escape handler (which would otherwise refocus another tab).
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
this.close();
|
||||
}
|
||||
};
|
||||
|
||||
// ---------- Popup rendering ----------
|
||||
|
||||
private openFor(state: AutocompleteState): void {
|
||||
this.state = state;
|
||||
this.items = this.buildItems(state);
|
||||
this.activeIdx = 0;
|
||||
if (!this.items.length) { this.close(); return; }
|
||||
this.renderPopup();
|
||||
this.pushScope();
|
||||
}
|
||||
|
||||
/** Push an Obsidian keymap Scope that consumes Escape so the
|
||||
* workspace's "Escape returns to last leaf" handler doesn't fire.
|
||||
* DOM-level stopPropagation isn't enough — Obsidian routes Escape
|
||||
* through its keymap before bubble-phase listeners run. */
|
||||
private pushScope(): void {
|
||||
if (this.scope) return;
|
||||
const scope = new Scope((this.app as any).scope);
|
||||
scope.register([], "Escape", (e) => {
|
||||
e.preventDefault();
|
||||
this.close();
|
||||
return false; // mark handled, stop further keymap dispatch
|
||||
});
|
||||
this.scope = scope;
|
||||
(this.app as any).keymap?.pushScope(scope);
|
||||
}
|
||||
|
||||
private popScope(): void {
|
||||
if (!this.scope) return;
|
||||
try { (this.app as any).keymap?.popScope(this.scope); } catch {}
|
||||
this.scope = null;
|
||||
}
|
||||
|
||||
private renderPopup(): void {
|
||||
if (!this.popupEl) {
|
||||
// Use the textarea's own document so the popup lands in the same
|
||||
// window — Obsidian secondary windows have their own document, and
|
||||
// a plain `document.body` always points at the main window.
|
||||
const doc = this.ta.ownerDocument ?? document;
|
||||
this.popupEl = doc.body.createDiv({ cls: "stashpad-composer-suggest" });
|
||||
// Make sure clicking anywhere on the popup chrome doesn't steal
|
||||
// focus from the textarea — we'd lose the caret position and the
|
||||
// input handler context.
|
||||
this.popupEl.tabIndex = -1;
|
||||
this.popupEl.addEventListener("mousedown", (e) => e.preventDefault());
|
||||
}
|
||||
const pop = this.popupEl;
|
||||
pop.empty();
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
const it = this.items[i];
|
||||
const row = pop.createDiv({ cls: "stashpad-composer-suggest-row" });
|
||||
if (i === this.activeIdx) row.addClass("is-active");
|
||||
row.createSpan({ cls: "stashpad-composer-suggest-label", text: it.label });
|
||||
if (it.subtitle) row.createSpan({ cls: "stashpad-composer-suggest-sub", text: it.subtitle });
|
||||
// Mousedown (not click) so the textarea blur fires AFTER our handler.
|
||||
row.onmousedown = (e) => {
|
||||
e.preventDefault();
|
||||
this.activeIdx = i;
|
||||
this.commit();
|
||||
};
|
||||
}
|
||||
this.position();
|
||||
}
|
||||
|
||||
private refreshActive(): void {
|
||||
if (!this.popupEl) return;
|
||||
const rows = this.popupEl.children;
|
||||
for (let i = 0; i < rows.length; i++) {
|
||||
(rows[i] as HTMLElement).toggleClass("is-active", i === this.activeIdx);
|
||||
}
|
||||
// Scroll the active row into view inside the popup (long lists).
|
||||
const active = rows[this.activeIdx] as HTMLElement | undefined;
|
||||
if (active) active.scrollIntoView({ block: "nearest" });
|
||||
}
|
||||
|
||||
private position(): void {
|
||||
if (!this.popupEl) return;
|
||||
const r = this.ta.getBoundingClientRect();
|
||||
// Anchor to the textarea's top-left, drop the popup just above the
|
||||
// textarea so it doesn't get clipped by the composer's bottom edge.
|
||||
const popH = this.popupEl.offsetHeight || 200;
|
||||
const top = r.top - popH - 4;
|
||||
const left = r.left;
|
||||
this.popupEl.style.left = `${Math.max(8, left)}px`;
|
||||
this.popupEl.style.top = `${Math.max(8, top)}px`;
|
||||
this.popupEl.style.minWidth = `${Math.min(360, r.width)}px`;
|
||||
}
|
||||
|
||||
private commit(): void {
|
||||
if (!this.state || !this.items.length) return;
|
||||
const item = this.items[this.activeIdx];
|
||||
if (!item) return;
|
||||
const before = this.ta.value.slice(0, this.state.replaceStart);
|
||||
const after = this.ta.value.slice(this.state.replaceEnd);
|
||||
const insert = item.insert;
|
||||
this.ta.value = before + insert + after;
|
||||
const caret = before.length + insert.length;
|
||||
this.ta.setSelectionRange(caret, caret);
|
||||
// Fire input so the composer's draft-save and any other listeners catch up.
|
||||
this.ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
this.close();
|
||||
this.ta.focus();
|
||||
}
|
||||
|
||||
private close(): void {
|
||||
if (this.popupEl) {
|
||||
this.popupEl.remove();
|
||||
this.popupEl = null;
|
||||
}
|
||||
this.state = null;
|
||||
this.items = [];
|
||||
this.activeIdx = 0;
|
||||
this.popScope();
|
||||
}
|
||||
}
|
||||
|
||||
interface SuggestItem {
|
||||
label: string;
|
||||
insert: string;
|
||||
subtitle: string;
|
||||
}
|
||||
|
||||
interface AutocompleteState {
|
||||
kind: "tag" | "link";
|
||||
query: string;
|
||||
/** Inclusive start index of the trigger (for replacement). For "[[foo"
|
||||
* this points at the first `[`; for "#foo" at the `#`. */
|
||||
replaceStart: number;
|
||||
/** Exclusive end index (the caret). */
|
||||
replaceEnd: number;
|
||||
}
|
||||
125
src/hotkey-recorder.ts
Normal file
125
src/hotkey-recorder.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
/**
|
||||
* Hotkey recorder: a small UI helper that wraps an HTMLInputElement and
|
||||
* captures the next key chord the user presses. Returns a normalized
|
||||
* Stashpad-style chord string:
|
||||
*
|
||||
* single-key (no modifiers): "S"
|
||||
* chord with modifiers: "Mod+Shift+ArrowUp"
|
||||
*
|
||||
* Modifier order is fixed (Mod, Ctrl, Alt, Shift) so equality compares
|
||||
* are stable. We translate the OS modifier (Cmd on Mac, Ctrl elsewhere)
|
||||
* into the literal "Mod" so cross-platform users don't have to think
|
||||
* about it. A bare Ctrl on Mac stays as "Ctrl" since it's a distinct
|
||||
* physical key from Cmd.
|
||||
*
|
||||
* Usage:
|
||||
* const stop = startHotkeyRecording(inputEl, (chord) => { ... });
|
||||
* stop(); // cancel without committing
|
||||
*/
|
||||
|
||||
const isMac = (() => {
|
||||
try {
|
||||
return typeof navigator !== "undefined"
|
||||
&& /Mac|iPhone|iPad|iPod/i.test(navigator.platform || navigator.userAgent || "");
|
||||
} catch { return false; }
|
||||
})();
|
||||
|
||||
/** Render a chord for display in inputs / labels. Same format as the stored
|
||||
* value, since we store the canonical form. */
|
||||
export function formatChord(chord: string): string {
|
||||
return chord || "";
|
||||
}
|
||||
|
||||
/** Pretty form for the bindings UI:
|
||||
* - Mod → "Cmd" on Mac, "Ctrl" elsewhere.
|
||||
* - Alt → "Option" on Mac (matches Apple keyboard caps).
|
||||
*/
|
||||
export function prettifyChord(chord: string): string {
|
||||
if (!chord) return "(none)";
|
||||
let out = chord.replace(/\bMod\b/g, isMac ? "Cmd" : "Ctrl");
|
||||
if (isMac) out = out.replace(/\bAlt\b/g, "Option");
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Begin capture on the input. The element gets `is-recording` class
|
||||
* while active. Calling the returned function aborts capture. */
|
||||
export function startHotkeyRecording(
|
||||
input: HTMLInputElement,
|
||||
onCapture: (chord: string) => void,
|
||||
opts: { allowSingleKey?: boolean } = { allowSingleKey: true },
|
||||
): () => void {
|
||||
const placeholderBefore = input.placeholder;
|
||||
input.placeholder = "Press a key… (Backspace to cancel)";
|
||||
input.value = "";
|
||||
input.classList.add("is-recording");
|
||||
|
||||
const cleanup = () => {
|
||||
input.placeholder = placeholderBefore;
|
||||
input.classList.remove("is-recording");
|
||||
input.removeEventListener("keydown", onKeyDown, true);
|
||||
input.removeEventListener("blur", onBlur);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: KeyboardEvent): void => {
|
||||
// Ignore standalone modifier presses — wait for the actual key.
|
||||
if (e.key === "Control" || e.key === "Shift" || e.key === "Alt"
|
||||
|| e.key === "Meta" || e.key === "OS") return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Backspace cancels recording without binding anything — Esc was a
|
||||
// bad choice because Obsidian's settings tab also listens for Esc and
|
||||
// would close the entire window. (On Mac the same key is labeled
|
||||
// "delete"; on Windows/Linux it's "Backspace".) When Backspace is
|
||||
// bound to a real shortcut (e.g. as part of Mod+Backspace), the
|
||||
// modifier prefix arrives as part of the chord — only a BARE
|
||||
// Backspace cancels.
|
||||
if (e.key === "Backspace" && !e.metaKey && !e.ctrlKey && !e.altKey && !e.shiftKey) {
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
// Cmd (Mac) and Ctrl (others) both translate to "Mod". A user pressing
|
||||
// both Ctrl and Cmd on Mac would yield Mod+Ctrl — a niche case but
|
||||
// represented honestly.
|
||||
if (isMac) {
|
||||
if (e.metaKey) parts.push("Mod");
|
||||
if (e.ctrlKey) parts.push("Ctrl");
|
||||
} else {
|
||||
if (e.ctrlKey) parts.push("Mod");
|
||||
}
|
||||
if (e.altKey) parts.push("Alt");
|
||||
if (e.shiftKey) parts.push("Shift");
|
||||
|
||||
const key = normalizeKey(e.key);
|
||||
if (!key) return;
|
||||
|
||||
if (parts.length === 0 && !opts.allowSingleKey) return;
|
||||
|
||||
parts.push(key);
|
||||
const chord = parts.join("+");
|
||||
cleanup();
|
||||
onCapture(chord);
|
||||
};
|
||||
|
||||
const onBlur = () => cleanup();
|
||||
|
||||
input.addEventListener("keydown", onKeyDown, true);
|
||||
input.addEventListener("blur", onBlur);
|
||||
return cleanup;
|
||||
}
|
||||
|
||||
/** Normalize KeyboardEvent.key to our chord vocabulary.
|
||||
* - Letters → uppercase.
|
||||
* - Arrow / Enter / etc → keep canonical name.
|
||||
* - Symbols (/, ;, etc) → keep as-is. */
|
||||
function normalizeKey(k: string): string {
|
||||
if (!k) return "";
|
||||
if (k.length === 1) {
|
||||
return k.toUpperCase();
|
||||
}
|
||||
// Multi-char keys: keep the canonical KeyboardEvent.key spelling.
|
||||
// Examples: "ArrowUp", "Enter", "Backspace", "Tab", "Escape", "PageUp".
|
||||
return k;
|
||||
}
|
||||
9
src/id-service.ts
Normal file
9
src/id-service.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const ALPHABET = "abcdefghijkmnpqrstuvwxyz23456789";
|
||||
|
||||
export function newId(len = 6): string {
|
||||
let out = "";
|
||||
const buf = new Uint8Array(len);
|
||||
crypto.getRandomValues(buf);
|
||||
for (let i = 0; i < len; i++) out += ALPHABET[buf[i] % ALPHABET.length];
|
||||
return out;
|
||||
}
|
||||
57
src/integrity-watcher.ts
Normal file
57
src/integrity-watcher.ts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import type { TreeIndex } from "./tree-index";
|
||||
import type { StashpadLog } from "./log";
|
||||
|
||||
export class IntegrityWatcher {
|
||||
constructor(private tree: TreeIndex, private log: StashpadLog) {}
|
||||
|
||||
/**
|
||||
* Compare the current folder's tree against the persisted state and log
|
||||
* deltas. The state file is shared across folders, so we filter it down
|
||||
* to entries under `folder` before diffing — otherwise switching folders
|
||||
* would log every note in the previously focused folder as missing.
|
||||
*/
|
||||
async sweep(folder?: string): Promise<void> {
|
||||
const prev = await this.log.readState();
|
||||
const cur = this.tree.snapshot();
|
||||
|
||||
const inFolder = (path: string): boolean => {
|
||||
if (!folder) return true;
|
||||
const f = folder.replace(/\/+$/, "");
|
||||
return path === f || path.startsWith(f + "/");
|
||||
};
|
||||
|
||||
for (const [id, info] of Object.entries(cur)) {
|
||||
const before = prev[id];
|
||||
if (!before) {
|
||||
await this.log.append({ type: "create", id, payload: { path: info.path, parent: info.parent } });
|
||||
} else if (before.parent !== info.parent) {
|
||||
await this.log.append({
|
||||
type: "parent_change", id,
|
||||
payload: { from: before.parent, to: info.parent },
|
||||
});
|
||||
} else if (before.path !== info.path) {
|
||||
await this.log.append({
|
||||
type: "rename", id,
|
||||
payload: { from: before.path, to: info.path },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only flag "missing" for notes that lived in the folder we just swept;
|
||||
// entries from other folders are out of scope for this sweep.
|
||||
for (const [id, info] of Object.entries(prev)) {
|
||||
if (!cur[id] && inFolder(info.path)) {
|
||||
await this.log.append({ type: "missing", id, payload: { lastPath: info.path } });
|
||||
}
|
||||
}
|
||||
|
||||
// Merge: keep state entries from other folders untouched, replace this
|
||||
// folder's entries with the fresh snapshot.
|
||||
const merged: Record<string, { parent: string | null; path: string }> = {};
|
||||
for (const [id, info] of Object.entries(prev)) {
|
||||
if (!inFolder(info.path)) merged[id] = info;
|
||||
}
|
||||
for (const [id, info] of Object.entries(cur)) merged[id] = info;
|
||||
await this.log.writeState(merged);
|
||||
}
|
||||
}
|
||||
96
src/log.ts
Normal file
96
src/log.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { App } from "obsidian";
|
||||
import type { LogEvent } from "./types";
|
||||
|
||||
/** StashpadLog persists action history + integrity state in a private
|
||||
* directory. The directory path is supplied by the caller — historically
|
||||
* it was `.stashpad/` at the vault root; it now defaults to the plugin's
|
||||
* own private folder (set via the plugin's pluginPrivatePath helper). */
|
||||
export class StashpadLog {
|
||||
/** Cached so we only check/create the directory once per session. */
|
||||
private dirOk = false;
|
||||
/** Serializes background log writes so concurrent appends don't trample each other. */
|
||||
private writeChain: Promise<void> = Promise.resolve();
|
||||
private readonly baseDir: string;
|
||||
private readonly logPath: string;
|
||||
private readonly statePath: string;
|
||||
|
||||
/** Optional getter the plugin supplies so every appended log line is
|
||||
* stamped with whoever performed the action. Lazy lookup (called on
|
||||
* every append) so a name change in settings is reflected on the
|
||||
* next log entry without re-wiring. Returns "" → field omitted. */
|
||||
private getAuthor: () => string;
|
||||
|
||||
constructor(private app: App, baseDir: string, getAuthor?: () => string) {
|
||||
this.baseDir = baseDir.replace(/\/+$/, "");
|
||||
this.logPath = `${this.baseDir}/log.jsonl`;
|
||||
this.statePath = `${this.baseDir}/state.json`;
|
||||
this.getAuthor = getAuthor ?? (() => "");
|
||||
}
|
||||
|
||||
/** Vault-relative path to the active log.jsonl — used by callers that
|
||||
* want to "Reveal in file explorer" or open the file directly. */
|
||||
getLogPath(): string { return this.logPath; }
|
||||
/** Vault-relative path to the directory holding log + state. */
|
||||
getDir(): string { return this.baseDir; }
|
||||
|
||||
private async ensureDir(): Promise<void> {
|
||||
if (this.dirOk) return;
|
||||
const adapter = this.app.vault.adapter;
|
||||
// mkdir intermediates (the dir might be nested several levels deep
|
||||
// under .obsidian/plugins/...).
|
||||
const parts = this.baseDir.split("/").filter(Boolean);
|
||||
let cur = "";
|
||||
for (const p of parts) {
|
||||
cur = cur ? `${cur}/${p}` : p;
|
||||
if (!(await adapter.exists(cur))) await adapter.mkdir(cur);
|
||||
}
|
||||
this.dirOk = true;
|
||||
}
|
||||
|
||||
/** Fire-and-forget append. The line is queued behind any in-flight writes
|
||||
* and persisted in the background. The log is write-only (nothing in the
|
||||
* plugin reads it back), so there's no need for callers to await this.
|
||||
* Errors are logged to the browser console instead of bubbling up. */
|
||||
append(ev: Omit<LogEvent, "ts"> & { ts?: string }): Promise<void> {
|
||||
// Auto-stamp author from the plugin's authorName setting if the
|
||||
// caller didn't already supply one. Most call sites don't think
|
||||
// about authorship; centralising it here means every log entry
|
||||
// (create / parent_change / delete / etc.) carries who did it.
|
||||
const author = ev.author ?? this.getAuthor() ?? "";
|
||||
const stamped: any = { ts: new Date().toISOString(), ...ev };
|
||||
if (author) stamped.author = author; else delete stamped.author;
|
||||
const line = JSON.stringify(stamped) + "\n";
|
||||
this.writeChain = this.writeChain.then(async () => {
|
||||
try {
|
||||
await this.ensureDir();
|
||||
const adapter: any = this.app.vault.adapter;
|
||||
if (typeof adapter.append === "function") {
|
||||
await adapter.append(this.logPath, line);
|
||||
} else {
|
||||
// Fallback for adapters without append(): existing read+rewrite.
|
||||
const existing = (await adapter.exists(this.logPath)) ? await adapter.read(this.logPath) : "";
|
||||
await adapter.write(this.logPath, existing + line);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Stashpad: log append failed", e);
|
||||
}
|
||||
});
|
||||
// Resolve immediately — callers that `await` get an instantly-resolved promise.
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
async readState(): Promise<Record<string, { parent: string | null; path: string }>> {
|
||||
const adapter = this.app.vault.adapter;
|
||||
if (!(await adapter.exists(this.statePath))) return {};
|
||||
try {
|
||||
return JSON.parse(await adapter.read(this.statePath));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async writeState(state: Record<string, { parent: string | null; path: string }>): Promise<void> {
|
||||
await this.ensureDir();
|
||||
await this.app.vault.adapter.write(this.statePath, JSON.stringify(state, null, 2));
|
||||
}
|
||||
}
|
||||
1366
src/main.ts
Normal file
1366
src/main.ts
Normal file
File diff suppressed because it is too large
Load diff
789
src/modals.ts
Normal file
789
src/modals.ts
Normal file
|
|
@ -0,0 +1,789 @@
|
|||
import { App, Modal, Platform, moment, Notice } from "obsidian";
|
||||
|
||||
interface LogEv { ts: string; type: string; id: string; payload?: any; author?: string; }
|
||||
|
||||
export class LogModal extends Modal {
|
||||
private events: LogEv[] = [];
|
||||
/** Currently-displayed slice of events (events filtered by typeFilter,
|
||||
* if set). Pagination + render counts always go through this. */
|
||||
private visible: LogEv[] = [];
|
||||
private shownCount = 0;
|
||||
private typeFilter: string | null = null;
|
||||
private listEl: HTMLDivElement | null = null;
|
||||
private footerEl: HTMLDivElement | null = null;
|
||||
private countEl: HTMLSpanElement | null = null;
|
||||
private filterSelEl: HTMLSelectElement | null = null;
|
||||
private static PAGE = 100;
|
||||
|
||||
constructor(app: App, private text: string, private jsonlPath: string) { super(app); }
|
||||
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText("Stashpad log");
|
||||
this.modalEl.addClass("stashpad-log-modal");
|
||||
|
||||
this.events = [];
|
||||
for (const line of this.text.trim().split(/\r?\n/)) {
|
||||
if (!line) continue;
|
||||
try { this.events.push(JSON.parse(line)); } catch {}
|
||||
}
|
||||
this.events.reverse();
|
||||
|
||||
const toolbar = this.contentEl.createDiv({ cls: "stashpad-log-toolbar" });
|
||||
this.countEl = toolbar.createSpan({ cls: "stashpad-log-count" }) as HTMLSpanElement;
|
||||
this.updateCount();
|
||||
|
||||
// Type filter dropdown. Built lazily here, repopulated by
|
||||
// refreshTypeFilter whenever events change (e.g. after Clear log).
|
||||
this.filterSelEl = toolbar.createEl("select", { cls: "stashpad-log-type-filter" });
|
||||
this.filterSelEl.onchange = () => this.setTypeFilter(this.filterSelEl!.value || null);
|
||||
this.refreshTypeFilter();
|
||||
|
||||
const revealBtn = toolbar.createEl("button", { text: "Reveal JSONL" });
|
||||
revealBtn.onclick = () => this.shellAct("reveal");
|
||||
const openBtn = toolbar.createEl("button", { text: "Open in default app" });
|
||||
openBtn.onclick = () => this.shellAct("open");
|
||||
|
||||
const copyBtn = toolbar.createEl("button", { text: "Copy raw JSONL" });
|
||||
let copyResetTimer: number | null = null;
|
||||
copyBtn.onclick = async () => {
|
||||
await navigator.clipboard.writeText(this.text);
|
||||
copyBtn.setText("Copied");
|
||||
if (copyResetTimer != null) window.clearTimeout(copyResetTimer);
|
||||
copyResetTimer = window.setTimeout(() => { copyBtn.setText("Copy raw JSONL"); copyResetTimer = null; }, 1500);
|
||||
};
|
||||
|
||||
const exportBtn = toolbar.createEl("button", { text: "Export copy" });
|
||||
exportBtn.onclick = () => this.exportCopy();
|
||||
|
||||
const clearBtn = toolbar.createEl("button", { cls: "mod-warning", text: "Clear log" });
|
||||
clearBtn.onclick = () => this.clearLog();
|
||||
|
||||
this.listEl = this.contentEl.createDiv({ cls: "stashpad-log-list" }) as HTMLDivElement;
|
||||
this.refreshList();
|
||||
|
||||
this.footerEl = this.contentEl.createDiv({ cls: "stashpad-log-footer" }) as HTMLDivElement;
|
||||
this.renderFooter();
|
||||
}
|
||||
|
||||
/** Recompute `visible` from `events` + `typeFilter`, then re-render
|
||||
* the list from scratch (resetting pagination). */
|
||||
private refreshList(): void {
|
||||
if (!this.listEl) return;
|
||||
this.visible = this.typeFilter
|
||||
? this.events.filter((ev) => ev.type === this.typeFilter)
|
||||
: this.events.slice();
|
||||
this.shownCount = 0;
|
||||
this.listEl.empty();
|
||||
if (!this.visible.length) {
|
||||
this.listEl.createDiv({
|
||||
cls: "stashpad-log-empty",
|
||||
text: this.typeFilter ? `No "${this.typeFilter}" events.` : "No events yet.",
|
||||
});
|
||||
this.updateCount();
|
||||
return;
|
||||
}
|
||||
this.appendMore(LogModal.PAGE);
|
||||
}
|
||||
|
||||
private setTypeFilter(type: string | null): void {
|
||||
if ((this.typeFilter ?? null) === (type ?? null)) return;
|
||||
this.typeFilter = type;
|
||||
this.refreshList();
|
||||
this.renderFooter();
|
||||
}
|
||||
|
||||
/** Rebuild the type-filter dropdown options from the current events.
|
||||
* Called on first render and after Clear log. */
|
||||
private refreshTypeFilter(): void {
|
||||
if (!this.filterSelEl) return;
|
||||
const sel = this.filterSelEl;
|
||||
sel.empty();
|
||||
const counts = new Map<string, number>();
|
||||
for (const ev of this.events) counts.set(ev.type, (counts.get(ev.type) ?? 0) + 1);
|
||||
const entries = [...counts.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|
||||
const all = sel.createEl("option", { text: `All types (${this.events.length})` });
|
||||
all.value = "";
|
||||
for (const [type, n] of entries) {
|
||||
const opt = sel.createEl("option", { text: `${type} (${n})` });
|
||||
opt.value = type;
|
||||
}
|
||||
// If the previously-selected filter no longer applies (e.g. its
|
||||
// type was cleared away), drop it.
|
||||
if (this.typeFilter && !counts.has(this.typeFilter)) {
|
||||
this.typeFilter = null;
|
||||
}
|
||||
sel.value = this.typeFilter ?? "";
|
||||
}
|
||||
|
||||
private updateCount(): void {
|
||||
if (!this.countEl) return;
|
||||
const total = this.visible.length;
|
||||
const labelTotal = this.typeFilter
|
||||
? `${total} ${this.typeFilter} event${total === 1 ? "" : "s"}`
|
||||
: `${total} event${total === 1 ? "" : "s"}`;
|
||||
if (this.shownCount === 0 || this.shownCount >= total) {
|
||||
this.countEl.setText(labelTotal);
|
||||
} else {
|
||||
this.countEl.setText(`Showing ${this.shownCount} of ${labelTotal}`);
|
||||
}
|
||||
}
|
||||
|
||||
private appendMore(n: number): void {
|
||||
if (!this.listEl) return;
|
||||
const stop = Math.min(this.visible.length, this.shownCount + n);
|
||||
for (let i = this.shownCount; i < stop; i++) {
|
||||
this.listEl.appendChild(this.renderEvent(this.visible[i]));
|
||||
}
|
||||
this.shownCount = stop;
|
||||
this.updateCount();
|
||||
}
|
||||
|
||||
private renderFooter(): void {
|
||||
if (!this.footerEl) return;
|
||||
this.footerEl.empty();
|
||||
// Pagination math now runs against the FILTERED set (visible).
|
||||
const remaining = this.visible.length - this.shownCount;
|
||||
if (remaining <= 0) return;
|
||||
const moreBtn = this.footerEl.createEl("button", {
|
||||
text: `Load ${Math.min(LogModal.PAGE, remaining)} more`,
|
||||
});
|
||||
moreBtn.onclick = () => { this.appendMore(LogModal.PAGE); this.renderFooter(); };
|
||||
if (remaining > LogModal.PAGE) {
|
||||
const allBtn = this.footerEl.createEl("button", { text: `Load all (${remaining})` });
|
||||
allBtn.onclick = () => { this.appendMore(remaining); this.renderFooter(); };
|
||||
}
|
||||
}
|
||||
|
||||
private shellAct(kind: "reveal" | "open"): void {
|
||||
try {
|
||||
const full = (this.app.vault.adapter as any).getFullPath?.(this.jsonlPath);
|
||||
if (!full) throw new Error("no full path");
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const { shell } = (window as any).require("electron");
|
||||
if (kind === "reveal") shell.showItemInFolder(full);
|
||||
else shell.openPath(full);
|
||||
} catch (e) {
|
||||
new Notice(`Couldn't ${kind}: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Write the current log content to <log-dir>/<timestamp>-log.jsonl —
|
||||
* same directory as the active log so the copy lives next to its
|
||||
* source. Derived from this.jsonlPath rather than hardcoded. */
|
||||
private async exportCopy(): Promise<string | null> {
|
||||
try {
|
||||
const stamp = (moment as any)().format("YYYY-MM-DD_HHmmss");
|
||||
const dir = this.jsonlPath.replace(/\/[^/]+$/, "") || "";
|
||||
const exportPath = dir ? `${dir}/${stamp}-log.jsonl` : `${stamp}-log.jsonl`;
|
||||
await this.app.vault.adapter.write(exportPath, this.text);
|
||||
new Notice(`Exported log → ${exportPath}`);
|
||||
return exportPath;
|
||||
} catch (e) {
|
||||
new Notice(`Export failed: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private clearLog(): void {
|
||||
new ConfirmModal(
|
||||
this.app,
|
||||
"Clear log?",
|
||||
"A timestamped copy will be saved alongside the active log before it's truncated.",
|
||||
"Export & clear",
|
||||
async (ok) => {
|
||||
if (!ok) return;
|
||||
const exported = await this.exportCopy();
|
||||
if (!exported) return; // bail if export failed; don't risk data loss
|
||||
try {
|
||||
await this.app.vault.adapter.write(this.jsonlPath, "");
|
||||
} catch (e) {
|
||||
new Notice(`Clear failed: ${(e as Error).message}`);
|
||||
return;
|
||||
}
|
||||
this.text = "";
|
||||
this.events = [];
|
||||
this.typeFilter = null;
|
||||
this.shownCount = 0;
|
||||
// Rebuild the dropdown so it shows just "All types (0)" again
|
||||
// and resets to that option.
|
||||
this.refreshTypeFilter();
|
||||
// refreshList renders the empty state and resets counts.
|
||||
this.refreshList();
|
||||
if (this.footerEl) this.footerEl.empty();
|
||||
new Notice("Log cleared.");
|
||||
},
|
||||
).open();
|
||||
}
|
||||
|
||||
private renderEvent(ev: LogEv): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = `stashpad-log-row stashpad-log-${ev.type}`;
|
||||
|
||||
const when = (moment as any)(ev.ts).isValid()
|
||||
? (moment as any)(ev.ts).format("YYYY-MM-DD HH:mm:ss")
|
||||
: ev.ts;
|
||||
|
||||
const ts = document.createElement("span");
|
||||
ts.className = "stashpad-log-ts";
|
||||
ts.textContent = when;
|
||||
row.appendChild(ts);
|
||||
|
||||
const type = document.createElement("span");
|
||||
type.className = "stashpad-log-type";
|
||||
type.textContent = ev.type;
|
||||
row.appendChild(type);
|
||||
|
||||
// Author column — author of THIS log entry (not the note's author).
|
||||
// For "create" actions this is the same as the note's author; for
|
||||
// "parent_change", "rename", "delete", etc., it tells you who
|
||||
// performed the action regardless of who originally created the
|
||||
// note. Older log lines without this field render as "—" so the
|
||||
// column stays aligned.
|
||||
const author = document.createElement("span");
|
||||
author.className = "stashpad-log-author";
|
||||
author.textContent = ev.author ? ev.author : "—";
|
||||
if (!ev.author) author.classList.add("is-empty");
|
||||
row.appendChild(author);
|
||||
|
||||
const msg = document.createElement("span");
|
||||
msg.className = "stashpad-log-msg";
|
||||
msg.textContent = this.describe(ev);
|
||||
row.appendChild(msg);
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
private describe(ev: LogEv): string {
|
||||
const p = ev.payload ?? {};
|
||||
switch (ev.type) {
|
||||
case "create": return `Created "${p.path ?? ev.id}" under ${p.parent ?? "?"}`;
|
||||
case "delete": {
|
||||
const atts = Array.isArray(p.attachmentsRemoved) ? p.attachmentsRemoved.length : 0;
|
||||
const merged = p.mergedInto ? ` (merged into ${p.mergedInto})` : "";
|
||||
return `Deleted ${ev.id}${merged}${atts ? ` — also removed ${atts} attachment${atts === 1 ? "" : "s"}` : ""}`;
|
||||
}
|
||||
case "missing": return `Missing: ${p.lastPath ?? ev.id}`;
|
||||
case "parent_change": {
|
||||
const reason = p.reason ? ` (${p.reason})` : "";
|
||||
return `Moved ${ev.id} from ${p.from ?? "null"} → ${p.to ?? "null"}${reason}`;
|
||||
}
|
||||
case "rename": return `Renamed ${p.from ?? "?"} → ${p.to ?? "?"}`;
|
||||
case "reorder": {
|
||||
const n = p.count ?? 1;
|
||||
const dir = p.dir ?? "?";
|
||||
const parent = p.parent ?? ev.id ?? "?";
|
||||
return `Reordered ${n} note${n === 1 ? "" : "s"} (${dir}) under ${parent}`;
|
||||
}
|
||||
case "complete": {
|
||||
const n = p.count ?? 1;
|
||||
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
|
||||
return `Marked ${n} note${n === 1 ? "" : "s"} complete${tag}`;
|
||||
}
|
||||
case "uncomplete": {
|
||||
const n = p.count ?? 1;
|
||||
const tag = p.undo ? " (undo)" : p.redo ? " (redo)" : "";
|
||||
return `Unmarked ${n} note${n === 1 ? "" : "s"}${tag}`;
|
||||
}
|
||||
case "stash_export": {
|
||||
const n = p.noteCount ?? "?";
|
||||
return `Exported ${n} note${n === 1 ? "" : "s"} → ${p.path ?? "?"}`;
|
||||
}
|
||||
case "stash_import": {
|
||||
const n = p.noteCount ?? "?";
|
||||
const extras: string[] = [];
|
||||
if (p.attachmentsWritten) extras.push(`${p.attachmentsWritten} attachment${p.attachmentsWritten === 1 ? "" : "s"}`);
|
||||
if (p.collisionsRenamed) extras.push(`${p.collisionsRenamed} renamed`);
|
||||
const tail = extras.length ? ` (${extras.join(", ")})` : "";
|
||||
return `Imported ${n} note${n === 1 ? "" : "s"} from ${p.from ?? "?"} → ${p.into ?? "?"}${tail}`;
|
||||
}
|
||||
case "attachment_add": return `Added attachment ${p.path ?? ev.id}`;
|
||||
case "attachment_remove": return `Removed attachment ${p.path ?? ev.id}`;
|
||||
case "palette_color_add": return `Added palette color ${p.color ?? ev.id}`;
|
||||
case "palette_color_remove": return `Removed palette color ${p.color ?? ev.id}`;
|
||||
default: return JSON.stringify(p);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
export class ConfirmDeleteModal extends Modal {
|
||||
constructor(
|
||||
app: App,
|
||||
private noteTitle: string,
|
||||
private descendantCount: number,
|
||||
private attachmentCount: number,
|
||||
/** When false, the "Also delete attachments" checkbox is suppressed
|
||||
* and onChoose is invoked with deleteAttachments=false. Lets the
|
||||
* caller open the modal purely for descendant/multi-select
|
||||
* confirmation while honoring the "Offer to delete attachments"
|
||||
* setting being OFF. */
|
||||
private offerAttachmentDelete: boolean,
|
||||
private onChoose: (deleteAttachments: boolean) => void,
|
||||
) { super(app); }
|
||||
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText(`Delete "${this.noteTitle}"?`);
|
||||
const parts: string[] = [];
|
||||
if (this.descendantCount > 0) parts.push(`${this.descendantCount} descendant note${this.descendantCount === 1 ? "" : "s"} will also be deleted.`);
|
||||
if (this.attachmentCount > 0) parts.push(`${this.attachmentCount} attachment${this.attachmentCount === 1 ? "" : "s"} found across these notes.`);
|
||||
this.contentEl.createEl("p", { text: parts.join(" ") });
|
||||
|
||||
let deleteAtts = this.offerAttachmentDelete && this.attachmentCount > 0;
|
||||
if (this.offerAttachmentDelete && this.attachmentCount > 0) {
|
||||
const label = this.contentEl.createEl("label", { cls: "stashpad-modal-check" });
|
||||
const cb = label.createEl("input", { type: "checkbox" }) as HTMLInputElement;
|
||||
cb.checked = deleteAtts;
|
||||
cb.onchange = () => { deleteAtts = cb.checked; };
|
||||
label.createSpan({ text: " Also delete attachments" });
|
||||
}
|
||||
|
||||
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
|
||||
const cancel = row.createEl("button", { text: "Cancel" });
|
||||
cancel.onclick = () => this.close();
|
||||
const del = row.createEl("button", { cls: "mod-warning", text: "Delete" });
|
||||
del.onclick = () => { this.close(); this.onChoose(deleteAtts); };
|
||||
}
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
export class SplitNoteModal extends Modal {
|
||||
private lines: string[];
|
||||
/** Line mode: index of the FIRST line of the second part (1..lines.length-1). */
|
||||
private lineCursorIdx: number;
|
||||
private mode: "line" | "cursor" = "line";
|
||||
private cursorTextarea: HTMLTextAreaElement | null = null;
|
||||
constructor(
|
||||
app: App,
|
||||
private body: string,
|
||||
private onSplitAtLine: (firstLineOfSecondPart: number) => void,
|
||||
private onSplitAtChar: (charIndex: number) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.lines = body.replace(/\r\n/g, "\n").split("\n");
|
||||
this.lineCursorIdx = Math.max(1, Math.min(this.lines.length - 1, Math.floor(this.lines.length / 2)));
|
||||
// Single-line notes can only be split via cursor mode.
|
||||
if (this.lines.length < 2) this.mode = "cursor";
|
||||
}
|
||||
|
||||
onOpen(): void {
|
||||
this.titleEl.setText("Split note");
|
||||
this.modalEl.addClass("stashpad-split-modal");
|
||||
this.render();
|
||||
// Tab toggles modes when both are available.
|
||||
this.scope.register([], "Tab", (e) => {
|
||||
if (this.lines.length < 2) return;
|
||||
e.preventDefault();
|
||||
this.mode = this.mode === "line" ? "cursor" : "line";
|
||||
this.render();
|
||||
});
|
||||
// Enter (no mods): commit in line mode; in cursor mode, let textarea insert a newline.
|
||||
this.scope.register([], "Enter", (e) => {
|
||||
if (this.mode !== "line") return; // pass through to textarea
|
||||
e.preventDefault();
|
||||
this.commitLine();
|
||||
});
|
||||
// Mod+Enter: commit in cursor mode.
|
||||
this.scope.register(["Mod"], "Enter", (e) => {
|
||||
if (this.mode !== "cursor") return;
|
||||
e.preventDefault();
|
||||
this.commitCursor();
|
||||
});
|
||||
// Arrows: in line mode, move the divider. In cursor mode, let textarea handle them.
|
||||
this.scope.register([], "ArrowUp", (e) => {
|
||||
if (this.mode !== "line") return;
|
||||
e.preventDefault();
|
||||
this.lineCursorIdx = Math.max(1, this.lineCursorIdx - 1);
|
||||
this.render();
|
||||
});
|
||||
this.scope.register([], "ArrowDown", (e) => {
|
||||
if (this.mode !== "line") return;
|
||||
e.preventDefault();
|
||||
this.lineCursorIdx = Math.min(this.lines.length - 1, this.lineCursorIdx + 1);
|
||||
this.render();
|
||||
});
|
||||
}
|
||||
|
||||
private commitLine(): void {
|
||||
const idx = this.lineCursorIdx;
|
||||
this.close();
|
||||
this.onSplitAtLine(idx);
|
||||
}
|
||||
|
||||
private commitCursor(): void {
|
||||
const ta = this.cursorTextarea;
|
||||
if (!ta) return;
|
||||
const ch = ta.selectionStart;
|
||||
if (ch <= 0 || ch >= ta.value.length) {
|
||||
new Notice("Move the cursor inside the text — neither end can be empty.");
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onSplitAtChar(ch);
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
this.contentEl.empty();
|
||||
|
||||
// Top bar: mode toggle on the left, Confirm button on the right.
|
||||
// The confirm button is essential on mobile where Enter is hijacked
|
||||
// by the textarea (cursor mode) or doesn't have a physical key
|
||||
// (some on-screen keyboards send "Done" instead).
|
||||
const bar = this.contentEl.createDiv({ cls: "stashpad-split-toggle-bar" });
|
||||
if (this.lines.length >= 2) {
|
||||
const lineBtn = bar.createEl("button", { text: "Line split", cls: "stashpad-split-mode-btn" });
|
||||
if (this.mode === "line") lineBtn.addClass("is-active");
|
||||
lineBtn.onclick = () => { this.mode = "line"; this.render(); };
|
||||
}
|
||||
const curBtn = bar.createEl("button", { text: "Cursor split", cls: "stashpad-split-mode-btn" });
|
||||
if (this.mode === "cursor") curBtn.addClass("is-active");
|
||||
curBtn.onclick = () => { this.mode = "cursor"; this.render(); };
|
||||
|
||||
const confirmBtn = bar.createEl("button", {
|
||||
text: "Split", cls: "stashpad-split-confirm-btn mod-cta",
|
||||
});
|
||||
confirmBtn.onmousedown = (e) => e.preventDefault(); // don't blur the textarea
|
||||
confirmBtn.onclick = () => {
|
||||
if (this.mode === "line") this.commitLine();
|
||||
else this.commitCursor();
|
||||
};
|
||||
|
||||
if (this.mode === "line") this.renderLineMode();
|
||||
else this.renderCursorMode();
|
||||
|
||||
const help = this.contentEl.createDiv({ cls: "stashpad-split-help" });
|
||||
if (Platform.isMobile) {
|
||||
help.setText(this.mode === "line"
|
||||
? "Tap a line to position the divider, then Split."
|
||||
: "Tap inside the text to position the cursor, then Split.");
|
||||
} else {
|
||||
help.setText(this.mode === "line"
|
||||
? "↑/↓ pick split line · Enter or Split confirm · Tab → cursor mode · Esc cancel · Children stay with the first part"
|
||||
: "Click or arrow to position cursor · Mod+Enter or Split confirm · Tab → line mode · Esc cancel · Children stay with the first part");
|
||||
}
|
||||
}
|
||||
|
||||
private renderLineMode(): void {
|
||||
const list = this.contentEl.createDiv({ cls: "stashpad-split-list" });
|
||||
for (let i = 0; i < this.lines.length; i++) {
|
||||
if (i === this.lineCursorIdx) {
|
||||
list.createDiv({ cls: "stashpad-split-divider", text: "── split here ──" });
|
||||
}
|
||||
const ln = list.createDiv({ cls: "stashpad-split-line" });
|
||||
ln.createSpan({ cls: "stashpad-split-lineno", text: String(i + 1) });
|
||||
ln.createSpan({ cls: "stashpad-split-text", text: this.lines[i] || " " });
|
||||
// Tap-to-position on mobile: tapping a line moves the divider to
|
||||
// start of THAT line (it becomes the first line of the second
|
||||
// part). On desktop this is also a nicer UX than only arrows.
|
||||
ln.onclick = () => {
|
||||
const target = Math.max(1, Math.min(this.lines.length - 1, i));
|
||||
if (target === this.lineCursorIdx) return;
|
||||
this.lineCursorIdx = target;
|
||||
this.render();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private renderCursorMode(): void {
|
||||
const wrap = this.contentEl.createDiv({ cls: "stashpad-split-cursor-wrap" });
|
||||
const ta = wrap.createEl("textarea", { cls: "stashpad-split-cursor-ta" }) as HTMLTextAreaElement;
|
||||
ta.value = this.body;
|
||||
ta.readOnly = false;
|
||||
this.cursorTextarea = ta;
|
||||
// Auto-size the textarea to fit content. Cap at 3 lines on mobile,
|
||||
// 12 lines on desktop. Recomputed on input in case the user edits.
|
||||
const lineHeight = parseFloat(getComputedStyle(ta).lineHeight) || 22;
|
||||
const maxLines = Platform.isMobile ? 3 : 12;
|
||||
const minLines = 2;
|
||||
const fit = (): void => {
|
||||
ta.style.height = "auto";
|
||||
const needed = Math.min(ta.scrollHeight, lineHeight * maxLines + 16);
|
||||
ta.style.height = `${Math.max(needed, lineHeight * minLines + 16)}px`;
|
||||
};
|
||||
requestAnimationFrame(() => {
|
||||
fit();
|
||||
const mid = Math.floor(ta.value.length / 2);
|
||||
ta.focus();
|
||||
ta.setSelectionRange(mid, mid);
|
||||
});
|
||||
ta.addEventListener("input", fit);
|
||||
}
|
||||
|
||||
onClose(): void {
|
||||
this.cursorTextarea = null;
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Submenu opened from the "+" tile in ColorPickerModal. Lets the user pick
|
||||
* any hex color, then either save (apply once) or add (apply + persist to
|
||||
* the palette). Closing without a button applies as "save" (consistent
|
||||
* with the user's hasty-apply expectation). */
|
||||
export class CustomColorModal extends Modal {
|
||||
private value: string;
|
||||
private delivered = false;
|
||||
constructor(
|
||||
app: App,
|
||||
seed: string | null,
|
||||
private onResult: (color: string | null, opts: { addToPalette: boolean }) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.value = seed && /^#[0-9a-f]{6}$/i.test(seed) ? seed : "#888888";
|
||||
}
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText("Custom color");
|
||||
this.modalEl.addClass("stashpad-custom-color-modal");
|
||||
|
||||
const row = this.contentEl.createDiv({ cls: "stashpad-custom-color-row" });
|
||||
const preview = row.createDiv({ cls: "stashpad-custom-color-preview" });
|
||||
preview.style.background = this.value;
|
||||
|
||||
// Native wheel — clicking the preview pops the OS color picker.
|
||||
const wheel = row.createEl("input", { type: "color" }) as HTMLInputElement;
|
||||
wheel.value = this.value;
|
||||
wheel.addClass("stashpad-custom-color-wheel");
|
||||
preview.onclick = () => wheel.click();
|
||||
|
||||
// Hex text input for direct entry. Synced both ways with the wheel.
|
||||
const hex = row.createEl("input", { type: "text" }) as HTMLInputElement;
|
||||
hex.addClass("stashpad-custom-color-hex");
|
||||
hex.placeholder = "#RRGGBB";
|
||||
hex.value = this.value;
|
||||
hex.maxLength = 7;
|
||||
|
||||
const sync = (next: string) => {
|
||||
const v = next.startsWith("#") ? next : "#" + next;
|
||||
if (!/^#[0-9a-f]{6}$/i.test(v)) return;
|
||||
this.value = v;
|
||||
preview.style.background = v;
|
||||
wheel.value = v;
|
||||
if (hex.value !== v) hex.value = v;
|
||||
};
|
||||
wheel.oninput = () => sync(wheel.value);
|
||||
hex.oninput = () => sync(hex.value);
|
||||
|
||||
const footer = this.contentEl.createDiv({ cls: "stashpad-color-footer" });
|
||||
const cancel = footer.createEl("button", { text: "Cancel" });
|
||||
cancel.onclick = () => { this.delivered = true; this.close(); };
|
||||
const save = footer.createEl("button", { cls: "mod-cta", text: "Save" });
|
||||
save.title = "Apply this color to the selection (without adding to your palette).";
|
||||
save.onclick = () => this.commit({ addToPalette: false });
|
||||
const add = footer.createEl("button", { cls: "mod-cta", text: "Add to palette" });
|
||||
add.title = "Apply and save this color so it appears as a tile next time.";
|
||||
add.onclick = () => this.commit({ addToPalette: true });
|
||||
|
||||
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: false }); });
|
||||
this.scope.register(["Mod"], "Enter", (e) => { e.preventDefault(); this.commit({ addToPalette: true }); });
|
||||
|
||||
requestAnimationFrame(() => hex.focus());
|
||||
}
|
||||
private commit(opts: { addToPalette: boolean }): void {
|
||||
this.delivered = true;
|
||||
this.close();
|
||||
this.onResult(this.value, opts);
|
||||
}
|
||||
onClose(): void {
|
||||
if (!this.delivered) {
|
||||
// Click-out / Esc → apply hastily (no palette persistence), matching
|
||||
// the user's "skip adding" intent.
|
||||
this.delivered = true;
|
||||
this.onResult(this.value, { addToPalette: false });
|
||||
}
|
||||
this.contentEl.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/** Compact color picker. Presents a grid of preset swatches and the user's
|
||||
* saved custom palette, plus a "+" tile that opens CustomColorModal for
|
||||
* arbitrary hex picking. The "no color" tile (slash) clears the assignment.
|
||||
*
|
||||
* Behavior:
|
||||
* - Arrow keys navigate the grid; Enter activates the highlighted tile.
|
||||
* For preset/saved tiles that means apply-and-close. For the "+" tile
|
||||
* it opens the custom-color submenu (this modal stays open behind it).
|
||||
* - There is no Save button on this modal — clicking a preset is the
|
||||
* explicit "apply" action.
|
||||
*/
|
||||
export class ColorPickerModal extends Modal {
|
||||
static DEFAULT_PALETTE: string[] = [
|
||||
"#E07A78", "#E08A47", "#E0A744", "#B0CC6E", "#6BC07A",
|
||||
"#5BA9CE", "#9B82C9", "#C57AB5", "#D75AA8",
|
||||
];
|
||||
|
||||
/** Active grid index for keyboard nav. -1 = no focus yet. */
|
||||
private focusIdx = -1;
|
||||
/** Snapshot of selectable tiles in render order. The "+" tile is `kind:
|
||||
* "add"` and opens CustomColorModal rather than committing directly. */
|
||||
private items: { kind: "none" | "preset" | "custom" | "add"; color: string | null; el: HTMLElement }[] = [];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private currentColor: string | null,
|
||||
private customPalette: string[],
|
||||
private onPick: (color: string | null, opts: { addToPalette?: boolean }) => void,
|
||||
/** Optional: invoked when the user clicks the ✕ on a saved tile.
|
||||
* The host should remove the color from settings.customPalette and log
|
||||
* the deletion. Returning a fresh palette array updates this modal in
|
||||
* place so the user can keep deleting. */
|
||||
private onDeleteCustom?: (color: string) => Promise<string[]> | string[],
|
||||
) { super(app); }
|
||||
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText("Pick a color");
|
||||
this.modalEl.addClass("stashpad-color-modal");
|
||||
|
||||
const grid = this.contentEl.createDiv({ cls: "stashpad-color-grid" });
|
||||
this.items = [];
|
||||
|
||||
// "No color" tile (slash).
|
||||
const noTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-none" });
|
||||
noTile.title = "No color";
|
||||
noTile.onclick = () => this.activate(0);
|
||||
this.items.push({ kind: "none", color: null, el: noTile });
|
||||
|
||||
const allPresets = [...ColorPickerModal.DEFAULT_PALETTE, ...this.customPalette];
|
||||
for (const c of allPresets) {
|
||||
const tile = grid.createDiv({ cls: "stashpad-color-tile" });
|
||||
tile.style.background = c;
|
||||
tile.title = c;
|
||||
const isCustom = !ColorPickerModal.DEFAULT_PALETTE.some((d) => d.toLowerCase() === c.toLowerCase());
|
||||
const idx = this.items.length;
|
||||
tile.onclick = () => this.activate(idx);
|
||||
// Saved-custom tiles get a ✕ that deletes them from the persisted
|
||||
// palette. Defaults aren't deletable.
|
||||
if (isCustom && this.onDeleteCustom) {
|
||||
const del = tile.createSpan({ cls: "stashpad-color-tile-del", text: "×" });
|
||||
del.title = "Remove from palette";
|
||||
del.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
void this.handleDelete(c);
|
||||
};
|
||||
}
|
||||
this.items.push({ kind: isCustom ? "custom" : "preset", color: c, el: tile });
|
||||
}
|
||||
|
||||
// "+" tile that opens the custom-color submenu.
|
||||
const addTile = grid.createDiv({ cls: "stashpad-color-tile stashpad-color-add" });
|
||||
addTile.title = "Custom color…";
|
||||
const addIdx = this.items.length;
|
||||
addTile.onclick = () => this.activate(addIdx);
|
||||
this.items.push({ kind: "add", color: null, el: addTile });
|
||||
|
||||
// Initial focus: the tile matching currentColor, else the first preset.
|
||||
const seedIdx = this.items.findIndex((it) => {
|
||||
if (this.currentColor === null) return it.kind === "none";
|
||||
if (!this.currentColor) return false;
|
||||
return it.color !== null && it.color.toLowerCase() === this.currentColor.toLowerCase();
|
||||
});
|
||||
this.focusIdx = seedIdx >= 0 ? seedIdx : 0;
|
||||
this.refreshActive();
|
||||
|
||||
// Keyboard nav: arrows move focus; Enter activates.
|
||||
this.scope.register([], "ArrowRight", (e) => { e.preventDefault(); this.moveFocus(1); });
|
||||
this.scope.register([], "ArrowLeft", (e) => { e.preventDefault(); this.moveFocus(-1); });
|
||||
this.scope.register([], "ArrowDown", (e) => { e.preventDefault(); this.moveFocus(this.columns()); });
|
||||
this.scope.register([], "ArrowUp", (e) => { e.preventDefault(); this.moveFocus(-this.columns()); });
|
||||
this.scope.register([], "Enter", (e) => { e.preventDefault(); this.activate(this.focusIdx); });
|
||||
|
||||
// After paint, focus the modal so arrow keys land here, not in the
|
||||
// background view.
|
||||
requestAnimationFrame(() => (this.modalEl as HTMLElement).focus());
|
||||
}
|
||||
|
||||
/** Click or Enter on a tile. Preset/saved/none → apply immediately + close.
|
||||
* Add → open the custom-color submenu (this modal hands off and closes). */
|
||||
private activate(i: number): void {
|
||||
if (i < 0 || i >= this.items.length) return;
|
||||
this.focusIdx = i;
|
||||
this.refreshActive();
|
||||
const it = this.items[i];
|
||||
if (it.kind === "add") {
|
||||
// Hand off to the custom submenu. We close this modal first so we
|
||||
// don't stack two on top of each other; the submenu fires onPick.
|
||||
this.close();
|
||||
new CustomColorModal(this.app, this.currentColor, (color, opts) => {
|
||||
this.onPick(color, opts);
|
||||
}).open();
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
this.onPick(it.color, { addToPalette: false });
|
||||
}
|
||||
|
||||
/** Approximate column count from the rendered grid (for vertical arrows). */
|
||||
private columns(): number {
|
||||
if (!this.items.length) return 1;
|
||||
const grid = this.items[0].el.parentElement;
|
||||
if (!grid) return 1;
|
||||
const gridRect = grid.getBoundingClientRect();
|
||||
const tileRect = this.items[0].el.getBoundingClientRect();
|
||||
if (tileRect.width <= 0) return 1;
|
||||
// Rough: total width / (tile + gap). We use the displayed positions to
|
||||
// count tiles in the first row instead — more robust to gap rounding.
|
||||
const firstTop = tileRect.top;
|
||||
let cols = 0;
|
||||
for (const it of this.items) {
|
||||
const r = it.el.getBoundingClientRect();
|
||||
if (Math.abs(r.top - firstTop) < 1) cols++;
|
||||
else break;
|
||||
}
|
||||
void gridRect;
|
||||
return Math.max(1, cols);
|
||||
}
|
||||
|
||||
private async handleDelete(color: string): Promise<void> {
|
||||
if (!this.onDeleteCustom) return;
|
||||
try {
|
||||
const next = await this.onDeleteCustom(color);
|
||||
// Re-render the grid in place with the updated palette so the user
|
||||
// can keep tidying.
|
||||
this.customPalette = Array.isArray(next) ? next : this.customPalette.filter((c) => c.toLowerCase() !== color.toLowerCase());
|
||||
this.onOpen();
|
||||
} catch (e) {
|
||||
// Swallow: host can show its own notice.
|
||||
console.warn("Stashpad: palette delete failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
private moveFocus(delta: number): void {
|
||||
if (!this.items.length) return;
|
||||
const next = Math.max(0, Math.min(this.items.length - 1, this.focusIdx + delta));
|
||||
this.focusIdx = next;
|
||||
this.refreshActive();
|
||||
}
|
||||
|
||||
private refreshActive(): void {
|
||||
for (let i = 0; i < this.items.length; i++) {
|
||||
this.items[i].el.toggleClass("is-active", i === this.focusIdx);
|
||||
}
|
||||
}
|
||||
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
|
||||
export class ConfirmModal extends Modal {
|
||||
constructor(
|
||||
app: App,
|
||||
private titleText: string,
|
||||
private message: string,
|
||||
private confirmText: string,
|
||||
private onChoose: (confirmed: boolean) => void,
|
||||
) { super(app); }
|
||||
onOpen(): void {
|
||||
this.contentEl.empty();
|
||||
this.titleEl.setText(this.titleText);
|
||||
this.contentEl.createEl("p", { text: this.message });
|
||||
const row = this.contentEl.createDiv({ cls: "stashpad-modal-btns" });
|
||||
const cancel = row.createEl("button", { text: "Cancel" });
|
||||
cancel.onclick = () => { this.close(); this.onChoose(false); };
|
||||
const ok = row.createEl("button", { cls: "mod-cta", text: this.confirmText });
|
||||
ok.onclick = () => { this.close(); this.onChoose(true); };
|
||||
// Focus the confirm button so Enter accepts.
|
||||
requestAnimationFrame(() => ok.focus());
|
||||
}
|
||||
onClose(): void { this.contentEl.empty(); }
|
||||
}
|
||||
249
src/note-picker.ts
Normal file
249
src/note-picker.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
import { App, SuggestModal, TFile } from "obsidian";
|
||||
import type { TreeIndex } from "./tree-index";
|
||||
import type { TreeNode } from "./types";
|
||||
import { ROOT_ID } from "./types";
|
||||
|
||||
export interface PickerItem {
|
||||
id: string;
|
||||
label: string;
|
||||
node: TreeNode | null;
|
||||
kind: "note" | "create";
|
||||
bodyPreview?: string; // for search mode
|
||||
matchLine?: number;
|
||||
/** For cross-folder results: the source folder + raw TFile so the
|
||||
* caller can switch view + focus appropriately. Empty/undefined for
|
||||
* local (current-tree) results. */
|
||||
crossFolder?: string;
|
||||
crossFile?: TFile;
|
||||
}
|
||||
|
||||
/** A cross-folder note loaded from another Stashpad. Shaped to plug into
|
||||
* the same render/filter machinery as in-tree notes without inventing a
|
||||
* full synthetic TreeNode. */
|
||||
export interface CrossFolderNote {
|
||||
file: TFile;
|
||||
folder: string;
|
||||
/** Note's id from frontmatter (just for stable item ids in the list). */
|
||||
id: string;
|
||||
/** Rendered title/label (basename minus the trailing -id, with dashes
|
||||
* → spaces). */
|
||||
title: string;
|
||||
/** Pre-loaded body text (the picker will lazy-read if blank). */
|
||||
body: string;
|
||||
/** Pre-loaded parent body's first line, prefixed with "Parent: " by
|
||||
* the renderer. Optional. */
|
||||
parentBlurb?: string;
|
||||
}
|
||||
|
||||
interface NoteBody {
|
||||
node: TreeNode | null;
|
||||
title: string;
|
||||
body: string;
|
||||
/** When set, this entry is from another Stashpad. */
|
||||
cross?: CrossFolderNote;
|
||||
}
|
||||
|
||||
export class StashpadSuggest extends SuggestModal<PickerItem> {
|
||||
private notes: NoteBody[] = [];
|
||||
|
||||
constructor(
|
||||
app: App,
|
||||
private tree: TreeIndex,
|
||||
private titleFn: (n: TreeNode) => string,
|
||||
private opts: {
|
||||
mode: "pick" | "search";
|
||||
placeholder?: string;
|
||||
allowCreate?: boolean;
|
||||
onPick: (item: PickerItem) => void;
|
||||
onCreate?: (query: string) => void;
|
||||
/** Optional source for cross-folder notes. Resolved lazily when
|
||||
* the user starts typing — local results from `tree` are returned
|
||||
* first, and this source is queried only after the local set is
|
||||
* exhausted (or to fill out short result lists). */
|
||||
crossFolderNotes?: () => CrossFolderNote[];
|
||||
},
|
||||
) {
|
||||
super(app);
|
||||
this.setPlaceholder(opts.placeholder ?? (opts.mode === "search" ? "Search notes…" : "Pick a note…"));
|
||||
this.loadAll();
|
||||
}
|
||||
|
||||
private loadAll(): void {
|
||||
const walk = (id: string, depth: number): void => {
|
||||
const node = this.tree.get(id);
|
||||
if (node?.file && id !== ROOT_ID) {
|
||||
this.notes.push({ node, title: `${" ".repeat(depth)}${this.titleFn(node)}`, body: "" });
|
||||
} else if (node?.file && id === ROOT_ID) {
|
||||
this.notes.push({ node, title: "Home", body: "" });
|
||||
}
|
||||
for (const c of this.tree.getChildren(id)) walk(c.id, depth + 1);
|
||||
};
|
||||
const rootNode = this.tree.getRoot();
|
||||
if (rootNode.file) this.notes.push({ node: rootNode, title: "Home", body: "" });
|
||||
for (const c of this.tree.getChildren(ROOT_ID)) walk(c.id, 1);
|
||||
|
||||
// lazy-read bodies in background
|
||||
for (const n of this.notes) {
|
||||
if (!n.node?.file) continue;
|
||||
this.app.vault.cachedRead(n.node.file).then((md) => { n.body = this.stripFm(md); });
|
||||
}
|
||||
|
||||
// Cross-folder notes (loaded once on first request, then cached on
|
||||
// this.notes alongside local entries — kept distinguished by the
|
||||
// .cross marker for tier ordering and rendering).
|
||||
if (this.opts.crossFolderNotes) {
|
||||
const cross = this.opts.crossFolderNotes();
|
||||
for (const c of cross) {
|
||||
this.notes.push({ node: null, title: c.title, body: c.body, cross: c });
|
||||
}
|
||||
for (const n of this.notes) {
|
||||
if (!n.cross || n.body) continue;
|
||||
this.app.vault.cachedRead(n.cross.file).then((md) => { n.body = this.stripFm(md); });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private stripFm(md: string): string {
|
||||
if (!md.startsWith("---")) return md;
|
||||
const end = md.indexOf("\n---", 3);
|
||||
return end === -1 ? md : md.slice(end + 4).replace(/^\r?\n/, "");
|
||||
}
|
||||
|
||||
getSuggestions(query: string): PickerItem[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
// Tier the candidates: local first (notes from the active tree),
|
||||
// then cross-folder (notes from other Stashpads). The user wanted
|
||||
// cross-folder results to appear AFTER the local ones rather than
|
||||
// intermingled, and only "kick in" once the local tier has been
|
||||
// exhausted (or shown as available).
|
||||
const local = this.notes.filter((n) => !n.cross);
|
||||
const cross = this.notes.filter((n) => n.cross);
|
||||
|
||||
const buildItem = (n: NoteBody, matchLine: number): PickerItem => ({
|
||||
id: n.cross ? `cross:${n.cross.id}` : n.node!.id,
|
||||
label: n.title,
|
||||
node: n.node,
|
||||
kind: "note",
|
||||
bodyPreview: this.previewFromBody(n.body, matchLine),
|
||||
matchLine,
|
||||
crossFolder: n.cross?.folder,
|
||||
crossFile: n.cross?.file,
|
||||
});
|
||||
|
||||
const matchTier = (tier: NoteBody[]): PickerItem[] => {
|
||||
const out: PickerItem[] = [];
|
||||
for (const n of tier) {
|
||||
if (this.opts.mode === "search") {
|
||||
if (!q) { out.push(buildItem(n, -1)); continue; }
|
||||
const titleHit = n.title.toLowerCase().includes(q);
|
||||
const lines = n.body.split(/\r?\n/);
|
||||
let matchLine = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].toLowerCase().includes(q)) { matchLine = i; break; }
|
||||
}
|
||||
if (!titleHit && matchLine === -1) continue;
|
||||
out.push(buildItem(n, matchLine));
|
||||
} else {
|
||||
// pick mode
|
||||
if (q && !n.title.toLowerCase().includes(q) && !n.body.toLowerCase().includes(q)) continue;
|
||||
out.push(buildItem(n, -1));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const localItems = matchTier(local);
|
||||
const items: PickerItem[] = [...localItems];
|
||||
// Only consult the cross-folder tier when local results are sparse
|
||||
// OR when the user is in search mode with a real query (so they can
|
||||
// discover cross-folder hits). Pick mode without a query keeps the
|
||||
// list local for performance.
|
||||
const crossWanted = this.opts.mode === "search"
|
||||
? (q ? true : localItems.length < 10)
|
||||
: (q ? localItems.length < 30 : false);
|
||||
if (crossWanted) {
|
||||
const crossItems = matchTier(cross);
|
||||
// Cap the local tier so the user sees the cross-folder section
|
||||
// come up without scrolling through hundreds of local hits.
|
||||
if (this.opts.mode === "search" && !q) items.length = Math.min(items.length, 50);
|
||||
items.push(...crossItems);
|
||||
}
|
||||
|
||||
if (this.opts.allowCreate && q && !items.some((i) => i.label.trim().toLowerCase() === q)) {
|
||||
items.push({ id: `__create__`, label: `Create new: "${query}"`, node: null, kind: "create" });
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
private previewFromBody(body: string, matchLine: number): string {
|
||||
const lines = body.split(/\r?\n/).filter((l) => l.trim().length > 0);
|
||||
if (!lines.length) return "";
|
||||
if (matchLine < 0) return lines.slice(0, 3).join("\n");
|
||||
const start = Math.max(0, matchLine - 1);
|
||||
const end = Math.min(lines.length, matchLine + 2);
|
||||
return lines.slice(start, end).join("\n");
|
||||
}
|
||||
|
||||
renderSuggestion(item: PickerItem, el: HTMLElement): void {
|
||||
el.addClass("stashpad-suggest-item");
|
||||
if (item.kind === "create") {
|
||||
el.createDiv({ cls: "stashpad-suggest-create", text: item.label });
|
||||
return;
|
||||
}
|
||||
if (item.crossFolder) el.addClass("is-cross-folder");
|
||||
|
||||
// Locate the underlying NoteBody so we can render body + parent body.
|
||||
const note = this.notes.find((n) => {
|
||||
if (item.crossFolder) return n.cross?.id === item.id.replace(/^cross:/, "");
|
||||
return n.node?.id === item.id;
|
||||
});
|
||||
// Top line: body's first non-empty line (or fallback to the label).
|
||||
const bodyTop = this.firstLineOfBody(note?.body ?? "") || item.label.trim();
|
||||
const top = el.createDiv({ cls: "stashpad-suggest-title", text: bodyTop });
|
||||
if (item.crossFolder) {
|
||||
top.createSpan({
|
||||
cls: "stashpad-suggest-folder",
|
||||
text: ` · ${item.crossFolder.split("/").pop() || item.crossFolder}`,
|
||||
});
|
||||
}
|
||||
// Bottom line: parent body. For local results, walk the tree. For
|
||||
// cross-folder results, the loader pre-supplies parentBlurb.
|
||||
let parentBlurb = "";
|
||||
if (item.crossFolder) parentBlurb = note?.cross?.parentBlurb ?? "";
|
||||
else parentBlurb = this.parentBlurbFor(item.node);
|
||||
if (parentBlurb) {
|
||||
const prev = el.createDiv({ cls: "stashpad-suggest-preview" });
|
||||
prev.setText(`Parent: ${parentBlurb}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** First non-empty line of a body string, with markdown noise trimmed. */
|
||||
private firstLineOfBody(body: string): string {
|
||||
if (!body) return "";
|
||||
const lines = body.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
/** First non-empty body line of the given node's parent. Reads from the
|
||||
* already-loaded body cache (lazy reads populate it in the background;
|
||||
* the line just won't render until that resolves). */
|
||||
private parentBlurbFor(node: TreeNode | null): string {
|
||||
if (!node || !node.parent || node.parent === ROOT_ID) return "";
|
||||
const parent = this.tree.get(node.parent);
|
||||
if (!parent || !parent.file) return "";
|
||||
const parentEntry = this.notes.find((n) => n.node?.id === parent.id);
|
||||
return parentEntry ? this.firstLineOfBody(parentEntry.body) : "";
|
||||
}
|
||||
|
||||
onChooseSuggestion(item: PickerItem): void {
|
||||
if (item.kind === "create" && this.opts.onCreate) {
|
||||
this.opts.onCreate((this as any).inputEl?.value ?? "");
|
||||
return;
|
||||
}
|
||||
this.opts.onPick(item);
|
||||
}
|
||||
}
|
||||
150
src/order-store.ts
Normal file
150
src/order-store.ts
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
import type { App } from "obsidian";
|
||||
import type { StashpadId } from "./types";
|
||||
|
||||
const ORDER_FILE = ".stashpad-order.json";
|
||||
|
||||
/** Per-folder ordering store. Maintains a `{ parentId: [childId, ...] }` map
|
||||
* in `<folder>/.stashpad-order.json`. Children present in the map sort by
|
||||
* their position in the array; children NOT in the map fall back to their
|
||||
* created-timestamp order, sorted after the explicit ones. */
|
||||
export class OrderStore {
|
||||
private cache = new Map<string, Record<string, string[]>>(); // folder -> map
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
/** Returns the order map for a folder (cached). Never throws. */
|
||||
async load(folder: string): Promise<Record<string, string[]>> {
|
||||
if (this.cache.has(folder)) return this.cache.get(folder)!;
|
||||
const path = `${folder}/${ORDER_FILE}`;
|
||||
const adapter = this.app.vault.adapter;
|
||||
let map: Record<string, string[]> = {};
|
||||
try {
|
||||
if (await adapter.exists(path)) {
|
||||
const text = await adapter.read(path);
|
||||
const parsed = JSON.parse(text);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (Array.isArray(v) && v.every((x) => typeof x === "string")) map[k] = v as string[];
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) { console.warn("Stashpad: order load failed", e); }
|
||||
this.cache.set(folder, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Schedule a debounced write of the order map. The cache is updated by
|
||||
* setOrder/appendChild/removeChild synchronously; this method only controls
|
||||
* when the cache lands on disk. A short coalescing window collapses bursts
|
||||
* of reorder writes (held-down keyboard repeat, rapid drag-and-drop) into
|
||||
* a single round-trip — meaningful on a network drive where each
|
||||
* adapter.write is a separate hop.
|
||||
*
|
||||
* `flush()` (below) forces an immediate write and is called on view
|
||||
* teardown so an Obsidian close during the debounce window can't drop
|
||||
* the latest order. */
|
||||
async save(folder: string): Promise<void> {
|
||||
this.scheduleWrite(folder);
|
||||
}
|
||||
|
||||
/** Force any pending debounced write for `folder` to land NOW. Idempotent. */
|
||||
async flush(folder: string): Promise<void> {
|
||||
const t = this.pendingTimers.get(folder);
|
||||
if (t != null) {
|
||||
window.clearTimeout(t);
|
||||
this.pendingTimers.delete(folder);
|
||||
}
|
||||
await this.writeNow(folder);
|
||||
}
|
||||
|
||||
private pendingTimers = new Map<string, number>();
|
||||
private writeInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
private scheduleWrite(folder: string): void {
|
||||
const existing = this.pendingTimers.get(folder);
|
||||
if (existing != null) window.clearTimeout(existing);
|
||||
const t = window.setTimeout(() => {
|
||||
this.pendingTimers.delete(folder);
|
||||
void this.writeNow(folder);
|
||||
}, 150);
|
||||
this.pendingTimers.set(folder, t);
|
||||
}
|
||||
|
||||
/** Serialize concurrent writes per folder so a flush() racing with a
|
||||
* scheduled write can't reorder the on-disk content. */
|
||||
private async writeNow(folder: string): Promise<void> {
|
||||
const prev = this.writeInFlight.get(folder) ?? Promise.resolve();
|
||||
const next = prev.then(() => this.doWrite(folder));
|
||||
this.writeInFlight.set(folder, next);
|
||||
try { await next; } finally {
|
||||
if (this.writeInFlight.get(folder) === next) this.writeInFlight.delete(folder);
|
||||
}
|
||||
}
|
||||
|
||||
private async doWrite(folder: string): Promise<void> {
|
||||
const map = this.cache.get(folder) ?? {};
|
||||
const trimmed: Record<string, string[]> = {};
|
||||
for (const [k, v] of Object.entries(map)) if (v.length > 0) trimmed[k] = v;
|
||||
this.cache.set(folder, trimmed);
|
||||
const path = `${folder}/${ORDER_FILE}`;
|
||||
const adapter = this.app.vault.adapter;
|
||||
try {
|
||||
if (Object.keys(trimmed).length === 0) {
|
||||
// Skip the exists() probe — remove() throws "file not found" if
|
||||
// it's missing, which we swallow. One round-trip instead of two
|
||||
// on a network drive.
|
||||
try { await adapter.remove(path); } catch { /* file already gone */ }
|
||||
} else {
|
||||
await adapter.write(path, JSON.stringify(trimmed, null, 2));
|
||||
}
|
||||
} catch (e) { console.warn("Stashpad: order save failed", e); }
|
||||
}
|
||||
|
||||
/** Get the explicit order for a parent (may be empty). Synchronous — caller must load first. */
|
||||
getOrder(folder: string, parentId: StashpadId): string[] {
|
||||
return this.cache.get(folder)?.[parentId]?.slice() ?? [];
|
||||
}
|
||||
|
||||
/** Replace the order for a parent. */
|
||||
setOrder(folder: string, parentId: StashpadId, ids: string[]): void {
|
||||
const map = this.cache.get(folder) ?? {};
|
||||
map[parentId] = ids.slice();
|
||||
this.cache.set(folder, map);
|
||||
}
|
||||
|
||||
/** Append a child to the parent's order (no-op if already present). */
|
||||
appendChild(folder: string, parentId: StashpadId, childId: StashpadId): void {
|
||||
const map = this.cache.get(folder) ?? {};
|
||||
const arr = map[parentId] ?? [];
|
||||
if (!arr.includes(childId)) arr.push(childId);
|
||||
map[parentId] = arr;
|
||||
this.cache.set(folder, map);
|
||||
}
|
||||
|
||||
/** Remove a child from any parent's order (used on delete / move). */
|
||||
removeChild(folder: string, childId: StashpadId): void {
|
||||
const map = this.cache.get(folder);
|
||||
if (!map) return;
|
||||
for (const arr of Object.values(map)) {
|
||||
const i = arr.indexOf(childId);
|
||||
if (i >= 0) arr.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
/** Drop the cache for a folder (forces re-read on next load). */
|
||||
invalidate(folder: string): void { this.cache.delete(folder); }
|
||||
}
|
||||
|
||||
/** Sort children using an explicit-order array; unknown ids fall to the end
|
||||
* in their existing order (typically created-timestamp order). */
|
||||
export function sortChildrenByOrder<T extends { id: string }>(children: T[], order: string[]): T[] {
|
||||
if (!order.length) return children;
|
||||
const positions = new Map<string, number>();
|
||||
order.forEach((id, i) => positions.set(id, i));
|
||||
return children.slice().sort((a, b) => {
|
||||
const pa = positions.has(a.id) ? positions.get(a.id)! : Infinity;
|
||||
const pb = positions.has(b.id) ? positions.get(b.id)! : Infinity;
|
||||
if (pa !== pb) return pa - pb;
|
||||
return 0; // stable: rely on input order for ties (already sorted by created)
|
||||
});
|
||||
}
|
||||
1069
src/settings.ts
Normal file
1069
src/settings.ts
Normal file
File diff suppressed because it is too large
Load diff
58
src/slug-service.ts
Normal file
58
src/slug-service.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/** Default stop-words trimmed out of slugs. Editable in settings. */
|
||||
export const DEFAULT_STOPWORDS = [
|
||||
"a","an","the","and","or","but","if","then","else","of","in","on","at","to",
|
||||
"for","with","by","from","as","is","are","was","were","be","been","being",
|
||||
"i","you","he","she","it","we","they","this","that","these","those","my",
|
||||
"your","our","their","do","does","did","so","just","very","really","im",
|
||||
];
|
||||
|
||||
const MAX_LEN = 50;
|
||||
|
||||
export function bodyToSlug(body: string, stopwords: string[] = DEFAULT_STOPWORDS): string {
|
||||
const stopSet = stopwords instanceof Set ? stopwords : new Set(stopwords.map((s) => s.toLowerCase()));
|
||||
const firstLine = (body.split(/\r?\n/).find((l) => l.trim().length > 0) ?? "").trim();
|
||||
if (!firstLine) return "Untitled";
|
||||
// Lowercase only for the stop-word filter; the emitted slug is Proper-Cased
|
||||
// so Obsidian's title display reads naturally ("My First Note" not
|
||||
// "my first note").
|
||||
// Strip common English contraction tails BEFORE removing punctuation,
|
||||
// so "Stash's" becomes "Stash" (not "Stash s") and we don't end up with
|
||||
// an orphan "s" segment in the slug. The character class covers ASCII
|
||||
// ', unicode right single quote U+2019, modifier letter apostrophe
|
||||
// U+02BC, and the common smart-quote variants U+2018/201A/201B.
|
||||
const cleaned = firstLine.replace(/['‘-‛ʼ](s|t|re|ll|ve|d|m)\b/gi, "");
|
||||
// Set of contraction tails to drop if they survive as bare tokens
|
||||
// (because the apostrophe was an exotic variant we missed). Without
|
||||
// this, "I'm falling asleep" → "M Falling Asleep" when the right-single-
|
||||
// quote stripper missed.
|
||||
const CONTRACTION_TAILS = new Set(["s", "t", "re", "ll", "ve", "d", "m"]);
|
||||
const words = cleaned
|
||||
.replace(/[^A-Za-z0-9\s]+/g, " ")
|
||||
.split(/\s+/)
|
||||
.filter((w) => w && !stopSet.has(w.toLowerCase()) && !CONTRACTION_TAILS.has(w.toLowerCase()))
|
||||
.map((w) => {
|
||||
// Smart proper-case: preserve all-caps tokens (HCC, NASA, US, etc.)
|
||||
// so acronyms don't read as "Hcc". A token counts as all-caps if
|
||||
// every alphabetic char is uppercase AND it has at least 2 chars
|
||||
// (single letters like "A" stay first-cap-only). Mixed-case tokens
|
||||
// get the standard "first up, rest down" treatment.
|
||||
if (w.length >= 2 && /^[A-Z0-9]+$/.test(w)) return w;
|
||||
return w.charAt(0).toUpperCase() + w.slice(1).toLowerCase();
|
||||
});
|
||||
let slug = "";
|
||||
for (const w of words) {
|
||||
const next = slug ? `${slug}-${w}` : w;
|
||||
if (next.length > MAX_LEN) break;
|
||||
slug = next;
|
||||
}
|
||||
return slug || "Untitled";
|
||||
}
|
||||
|
||||
export function buildFilename(slug: string, id: string): string {
|
||||
return `${slug}-${id}.md`;
|
||||
}
|
||||
|
||||
export function parseIdFromFilename(basename: string): string | null {
|
||||
const m = basename.match(/-([a-z0-9]{4,12})$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
151
src/sort-store.ts
Normal file
151
src/sort-store.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { App } from "obsidian";
|
||||
import type { StashpadId } from "./types";
|
||||
|
||||
/** Per-folder sort-mode store. Mirrors the OrderStore pattern: maintains a
|
||||
* `{ parentId: SortMode }` map in `<folder>/.stashpad-sort.json`, loaded
|
||||
* on view bootstrap and consulted by the tree's orderProvider.
|
||||
*
|
||||
* - "manual" is the default and is NEVER persisted (it's the absence of
|
||||
* an entry). Auto-flip-to-manual on drag/keyboard reorder is implemented
|
||||
* by deleting the parent's entry.
|
||||
* - Any non-manual mode is persisted explicitly so navigating away and
|
||||
* back restores the user's chosen sort for each parent independently.
|
||||
* - Granularity is per-parent — each parent in the tree carries its own
|
||||
* sort mode (or falls through to manual). The home/root parent uses
|
||||
* ROOT_ID as its key. */
|
||||
export type SortMode =
|
||||
| "manual"
|
||||
| "created-asc"
|
||||
| "created-desc"
|
||||
| "modified-asc"
|
||||
| "modified-desc"
|
||||
| "title-az"
|
||||
| "title-za";
|
||||
|
||||
export const SORT_MODE_LABELS: Record<SortMode, string> = {
|
||||
"manual": "Manual",
|
||||
"created-asc": "Created — oldest first",
|
||||
"created-desc": "Created — newest first",
|
||||
"modified-asc": "Modified — oldest first",
|
||||
"modified-desc": "Modified — newest first",
|
||||
"title-az": "Title — A→Z",
|
||||
"title-za": "Title — Z→A",
|
||||
};
|
||||
|
||||
export const SORT_MODES_ORDER: SortMode[] = [
|
||||
"manual",
|
||||
"created-asc", "created-desc",
|
||||
"modified-asc", "modified-desc",
|
||||
"title-az", "title-za",
|
||||
];
|
||||
|
||||
const VALID_MODES = new Set<string>(SORT_MODES_ORDER);
|
||||
|
||||
const SORT_FILE = ".stashpad-sort.json";
|
||||
|
||||
export class SortStore {
|
||||
/** folder -> { parentId -> SortMode } (excluding "manual" entries) */
|
||||
private cache = new Map<string, Record<string, SortMode>>();
|
||||
|
||||
constructor(private app: App) {}
|
||||
|
||||
/** Load the per-parent sort-mode map for a folder. Never throws. */
|
||||
async load(folder: string): Promise<Record<string, SortMode>> {
|
||||
if (this.cache.has(folder)) return this.cache.get(folder)!;
|
||||
const path = `${folder}/${SORT_FILE}`;
|
||||
const adapter = this.app.vault.adapter;
|
||||
const map: Record<string, SortMode> = {};
|
||||
try {
|
||||
if (await adapter.exists(path)) {
|
||||
const parsed = JSON.parse(await adapter.read(path));
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v === "string" && VALID_MODES.has(v)) map[k] = v as SortMode;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Stashpad: sort load failed", e);
|
||||
}
|
||||
this.cache.set(folder, map);
|
||||
return map;
|
||||
}
|
||||
|
||||
/** Schedule a debounced write of the sort-mode map. See OrderStore.save for
|
||||
* the rationale — same pattern, same window. */
|
||||
async save(folder: string): Promise<void> {
|
||||
this.scheduleWrite(folder);
|
||||
}
|
||||
|
||||
/** Force any pending debounced write for `folder` to land NOW. Idempotent. */
|
||||
async flush(folder: string): Promise<void> {
|
||||
const t = this.pendingTimers.get(folder);
|
||||
if (t != null) {
|
||||
window.clearTimeout(t);
|
||||
this.pendingTimers.delete(folder);
|
||||
}
|
||||
await this.writeNow(folder);
|
||||
}
|
||||
|
||||
private pendingTimers = new Map<string, number>();
|
||||
private writeInFlight = new Map<string, Promise<void>>();
|
||||
|
||||
private scheduleWrite(folder: string): void {
|
||||
const existing = this.pendingTimers.get(folder);
|
||||
if (existing != null) window.clearTimeout(existing);
|
||||
const t = window.setTimeout(() => {
|
||||
this.pendingTimers.delete(folder);
|
||||
void this.writeNow(folder);
|
||||
}, 150);
|
||||
this.pendingTimers.set(folder, t);
|
||||
}
|
||||
|
||||
private async writeNow(folder: string): Promise<void> {
|
||||
const prev = this.writeInFlight.get(folder) ?? Promise.resolve();
|
||||
const next = prev.then(() => this.doWrite(folder));
|
||||
this.writeInFlight.set(folder, next);
|
||||
try { await next; } finally {
|
||||
if (this.writeInFlight.get(folder) === next) this.writeInFlight.delete(folder);
|
||||
}
|
||||
}
|
||||
|
||||
private async doWrite(folder: string): Promise<void> {
|
||||
const map = this.cache.get(folder) ?? {};
|
||||
const path = `${folder}/${SORT_FILE}`;
|
||||
const adapter = this.app.vault.adapter;
|
||||
try {
|
||||
if (Object.keys(map).length === 0) {
|
||||
// Skip the exists() probe — remove() throws if it's missing, which
|
||||
// we swallow. One round-trip instead of two on a network drive.
|
||||
try { await adapter.remove(path); } catch { /* file already gone */ }
|
||||
} else {
|
||||
await adapter.write(path, JSON.stringify(map, null, 2));
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Stashpad: sort save failed", e);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get a parent's sort mode. Falls back to "manual" when no entry exists. */
|
||||
getMode(folder: string, parentId: StashpadId): SortMode {
|
||||
return this.cache.get(folder)?.[parentId] ?? "manual";
|
||||
}
|
||||
|
||||
/** Set a parent's sort mode. Passing "manual" deletes the entry (manual is the
|
||||
* absence of an entry — keeps the json file compact). */
|
||||
setMode(folder: string, parentId: StashpadId, mode: SortMode): void {
|
||||
const map = this.cache.get(folder) ?? {};
|
||||
if (mode === "manual") delete map[parentId];
|
||||
else map[parentId] = mode;
|
||||
this.cache.set(folder, map);
|
||||
}
|
||||
|
||||
/** Drop a parent's entry — used when a parent note is deleted. */
|
||||
removeParent(folder: string, parentId: StashpadId): void {
|
||||
const map = this.cache.get(folder);
|
||||
if (!map) return;
|
||||
delete map[parentId];
|
||||
}
|
||||
|
||||
invalidate(folder: string): void { this.cache.delete(folder); }
|
||||
}
|
||||
285
src/stash-package.ts
Normal file
285
src/stash-package.ts
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
import JSZip from "jszip";
|
||||
import { App, TFile, parseYaml, stringifyYaml } from "obsidian";
|
||||
import { newId } from "./id-service";
|
||||
import { ROOT_ID, type StashpadId } from "./types";
|
||||
|
||||
export const STASH_EXT = "stash";
|
||||
export const SCHEMA_VERSION = 1;
|
||||
const ATTACHMENT_LINK_RE = /!\[\[([^\]\|]+)(?:\|[^\]]+)?\]\]/g;
|
||||
|
||||
export interface StashManifest {
|
||||
stashSchema: number;
|
||||
exportedAt: string;
|
||||
sourceFolder: string;
|
||||
noteCount: number;
|
||||
rootIds: StashpadId[];
|
||||
}
|
||||
|
||||
export interface ExportInput {
|
||||
/** Notes to export. Children of these notes will be auto-included. */
|
||||
rootNotes: { id: StashpadId; file: TFile }[];
|
||||
/** Children-of-roots resolver (recursive walk handled by caller). */
|
||||
allDescendants: { id: StashpadId; file: TFile }[];
|
||||
/** Folder the source notes live in (for the manifest). */
|
||||
sourceFolder: string;
|
||||
}
|
||||
|
||||
export interface ImportSummary {
|
||||
notesWritten: number;
|
||||
attachmentsWritten: number;
|
||||
collisionsRenamed: number;
|
||||
warnings: string[];
|
||||
}
|
||||
|
||||
interface ParsedNote {
|
||||
originalName: string;
|
||||
fm: Record<string, any>;
|
||||
body: string;
|
||||
}
|
||||
|
||||
// ---------------- Export ----------------
|
||||
|
||||
export async function buildStashZip(app: App, input: ExportInput): Promise<Uint8Array> {
|
||||
const zip = new JSZip();
|
||||
const allNotes = dedupeById([...input.rootNotes, ...input.allDescendants]);
|
||||
const collectedAtts = new Map<string, ArrayBuffer>(); // basename -> binary
|
||||
const warnings: string[] = [];
|
||||
|
||||
for (const n of allNotes) {
|
||||
const md = await app.vault.read(n.file);
|
||||
let rewritten = md;
|
||||
const refs = extractAttachmentRefs(md);
|
||||
for (const ref of refs) {
|
||||
const af = app.metadataCache.getFirstLinkpathDest(ref, n.file.path);
|
||||
if (!af) {
|
||||
warnings.push(`Missing attachment "${ref}" in ${n.file.path}`);
|
||||
continue;
|
||||
}
|
||||
const basename = af.name;
|
||||
if (!collectedAtts.has(basename)) {
|
||||
collectedAtts.set(basename, await app.vault.readBinary(af));
|
||||
}
|
||||
// Rewrite: ![[some/path/foo.png]] -> ![[foo.png]]
|
||||
rewritten = rewriteAttachmentRef(rewritten, ref, basename);
|
||||
}
|
||||
// Also normalize attachments: list in frontmatter to bare basenames.
|
||||
rewritten = rewriteFrontmatterAttachmentList(rewritten, app, n.file.path);
|
||||
zip.file(`notes/${n.file.name}`, rewritten);
|
||||
}
|
||||
|
||||
for (const [name, buf] of collectedAtts) {
|
||||
zip.file(`attachments/${name}`, buf);
|
||||
}
|
||||
|
||||
const manifest: StashManifest = {
|
||||
stashSchema: SCHEMA_VERSION,
|
||||
exportedAt: new Date().toISOString(),
|
||||
sourceFolder: input.sourceFolder,
|
||||
noteCount: allNotes.length,
|
||||
rootIds: input.rootNotes.map((n) => n.id),
|
||||
};
|
||||
zip.file("manifest.json", JSON.stringify(manifest, null, 2));
|
||||
|
||||
if (warnings.length) {
|
||||
zip.file("warnings.txt", warnings.join("\n"));
|
||||
}
|
||||
|
||||
return zip.generateAsync({ type: "uint8array", compression: "DEFLATE", compressionOptions: { level: 6 } });
|
||||
}
|
||||
|
||||
// ---------------- Import ----------------
|
||||
|
||||
export async function importStashZip(
|
||||
app: App,
|
||||
buf: ArrayBuffer | Uint8Array,
|
||||
destFolder: string,
|
||||
existingIds: Set<StashpadId>,
|
||||
): Promise<ImportSummary> {
|
||||
const zip = await JSZip.loadAsync(buf as any);
|
||||
const manifestFile = zip.file("manifest.json");
|
||||
if (!manifestFile) throw new Error("Not a valid .stash package: missing manifest.json");
|
||||
const manifest = JSON.parse(await manifestFile.async("string")) as StashManifest;
|
||||
if (typeof manifest.stashSchema !== "number" || manifest.stashSchema > SCHEMA_VERSION) {
|
||||
throw new Error(`Unsupported .stash schema: v${manifest.stashSchema}`);
|
||||
}
|
||||
|
||||
await ensureFolder(app, destFolder);
|
||||
|
||||
// Read all note entries.
|
||||
const noteEntries = Object.values(zip.files).filter(
|
||||
(f) => !f.dir && f.name.startsWith("notes/") && f.name.endsWith(".md"),
|
||||
);
|
||||
const parsed: ParsedNote[] = [];
|
||||
for (const f of noteEntries) {
|
||||
const content = await f.async("string");
|
||||
const { fm, body } = splitFrontmatter(content);
|
||||
parsed.push({ originalName: f.name.slice("notes/".length), fm, body });
|
||||
}
|
||||
|
||||
// Build id remap (collision-aware).
|
||||
const idRemap = new Map<StashpadId, StashpadId>();
|
||||
let collisionsRenamed = 0;
|
||||
for (const p of parsed) {
|
||||
const oldId = p.fm.id as string | undefined;
|
||||
if (!oldId) continue;
|
||||
if (existingIds.has(oldId) || idRemap.has(oldId) /* dup within zip */) {
|
||||
idRemap.set(oldId, `${oldId}-${newId(4)}-Imported`);
|
||||
collisionsRenamed++;
|
||||
} else {
|
||||
idRemap.set(oldId, oldId);
|
||||
}
|
||||
}
|
||||
|
||||
const importDate = new Date().toISOString();
|
||||
const warnings: string[] = [];
|
||||
const attachmentsFolder = `${destFolder}/_attachments`;
|
||||
|
||||
// Write attachments first so notes referencing them land on disk first.
|
||||
let attachmentsWritten = 0;
|
||||
const attEntries = Object.values(zip.files).filter(
|
||||
(f) => !f.dir && f.name.startsWith("attachments/"),
|
||||
);
|
||||
if (attEntries.length > 0) await ensureFolder(app, attachmentsFolder);
|
||||
for (const f of attEntries) {
|
||||
const basename = f.name.slice("attachments/".length);
|
||||
if (!basename) continue;
|
||||
const destPath = `${attachmentsFolder}/${basename}`;
|
||||
if (await app.vault.adapter.exists(destPath)) continue; // dedup by name
|
||||
const buf = await f.async("arraybuffer");
|
||||
await app.vault.createBinary(destPath, buf);
|
||||
attachmentsWritten++;
|
||||
}
|
||||
|
||||
// Write notes with remapped ids/parents and import_date.
|
||||
let notesWritten = 0;
|
||||
for (const p of parsed) {
|
||||
const oldId = p.fm.id as string | undefined;
|
||||
if (!oldId) { warnings.push(`Skipped ${p.originalName} — no id in frontmatter`); continue; }
|
||||
const newIdVal = idRemap.get(oldId)!;
|
||||
|
||||
const oldParent = (p.fm.parent ?? null) as string | null;
|
||||
let newParent: string | null = oldParent;
|
||||
if (oldParent && oldParent !== ROOT_ID && idRemap.has(oldParent)) {
|
||||
newParent = idRemap.get(oldParent)!;
|
||||
} else if (oldParent && oldParent !== ROOT_ID && !idRemap.has(oldParent)) {
|
||||
// Parent isn't part of this export — pin to ROOT for safety.
|
||||
newParent = ROOT_ID;
|
||||
}
|
||||
|
||||
// Rewrite body: ![[basename]] -> ![[<attachmentsFolder>/basename]]
|
||||
const rewrittenBody = rewriteImportedAttachmentLinks(p.body, attachmentsFolder);
|
||||
|
||||
const newFm: Record<string, any> = {
|
||||
...p.fm,
|
||||
id: newIdVal,
|
||||
parent: newParent,
|
||||
import_date: importDate,
|
||||
};
|
||||
if (Array.isArray(newFm.attachments)) {
|
||||
newFm.attachments = (newFm.attachments as string[]).map((a) =>
|
||||
`${attachmentsFolder}/${baseFileName(a)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const finalContent = serializeNote(newFm, rewrittenBody);
|
||||
|
||||
// Filename: prefer original; if id changed, replace short id suffix; if collision on disk, suffix.
|
||||
let outName = newIdVal === oldId ? p.originalName : remixFilename(p.originalName, oldId, newIdVal);
|
||||
let outPath = `${destFolder}/${outName}`;
|
||||
if (await app.vault.adapter.exists(outPath)) {
|
||||
const stem = outName.replace(/\.md$/, "");
|
||||
outName = `${stem}-${newId(4)}.md`;
|
||||
outPath = `${destFolder}/${outName}`;
|
||||
}
|
||||
await app.vault.create(outPath, finalContent);
|
||||
notesWritten++;
|
||||
}
|
||||
|
||||
return { notesWritten, attachmentsWritten, collisionsRenamed, warnings };
|
||||
}
|
||||
|
||||
// ---------------- Helpers ----------------
|
||||
|
||||
function dedupeById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
const out: T[] = [];
|
||||
for (const it of items) {
|
||||
if (seen.has(it.id)) continue;
|
||||
seen.add(it.id);
|
||||
out.push(it);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function extractAttachmentRefs(md: string): string[] {
|
||||
const out = new Set<string>();
|
||||
let m: RegExpExecArray | null;
|
||||
ATTACHMENT_LINK_RE.lastIndex = 0;
|
||||
while ((m = ATTACHMENT_LINK_RE.exec(md)) !== null) out.add(m[1]);
|
||||
return [...out];
|
||||
}
|
||||
|
||||
function rewriteAttachmentRef(md: string, oldRef: string, basename: string): string {
|
||||
// Replace exactly inside ![[...]] occurrences only.
|
||||
return md.replace(new RegExp(`!\\[\\[${escapeRegex(oldRef)}(\\|[^\\]]+)?\\]\\]`, "g"),
|
||||
(_m, alias) => `![[${basename}${alias ?? ""}]]`);
|
||||
}
|
||||
|
||||
function rewriteImportedAttachmentLinks(body: string, attachmentsFolder: string): string {
|
||||
return body.replace(ATTACHMENT_LINK_RE, (match, ref: string, _aliasRaw) => {
|
||||
// If ref already contains a slash, leave it alone (assume the importer wants a specific path).
|
||||
if (ref.includes("/")) return match;
|
||||
return match.replace(ref, `${attachmentsFolder}/${ref}`);
|
||||
});
|
||||
}
|
||||
|
||||
function rewriteFrontmatterAttachmentList(md: string, app: App, notePath: string): string {
|
||||
const split = splitFrontmatter(md);
|
||||
if (!split.fm.attachments || !Array.isArray(split.fm.attachments)) return md;
|
||||
const remapped = (split.fm.attachments as string[]).map((a) => {
|
||||
const af = app.metadataCache.getFirstLinkpathDest(a, notePath);
|
||||
return af ? af.name : baseFileName(a);
|
||||
});
|
||||
const newFm = { ...split.fm, attachments: remapped };
|
||||
return serializeNote(newFm, split.body);
|
||||
}
|
||||
|
||||
function escapeRegex(s: string): string {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function baseFileName(p: string): string {
|
||||
const i = p.lastIndexOf("/");
|
||||
return i < 0 ? p : p.slice(i + 1);
|
||||
}
|
||||
|
||||
function remixFilename(originalName: string, oldId: string, newId: string): string {
|
||||
// Filenames look like "{slug}-{shortid}.md". Replace the trailing "-{oldId}" if present.
|
||||
if (originalName.includes(oldId)) return originalName.replace(oldId, newId);
|
||||
return originalName.replace(/\.md$/, `-${newId}.md`);
|
||||
}
|
||||
|
||||
function splitFrontmatter(content: string): { fm: Record<string, any>; body: string } {
|
||||
if (!content.startsWith("---")) return { fm: {}, body: content };
|
||||
const end = content.indexOf("\n---", 3);
|
||||
if (end < 0) return { fm: {}, body: content };
|
||||
const yamlText = content.slice(3, end).replace(/^\n/, "");
|
||||
const after = content.slice(end + 4);
|
||||
let fm: Record<string, any> = {};
|
||||
try { fm = (parseYaml(yamlText) as Record<string, any>) ?? {}; } catch { fm = {}; }
|
||||
// Strip a leading newline from body if present.
|
||||
const body = after.startsWith("\n") ? after.slice(1) : after;
|
||||
return { fm, body };
|
||||
}
|
||||
|
||||
function serializeNote(fm: Record<string, any>, body: string): string {
|
||||
const yaml = stringifyYaml(fm).trimEnd();
|
||||
return `---\n${yaml}\n---\n${body}`;
|
||||
}
|
||||
|
||||
async function ensureFolder(app: App, path: string): Promise<void> {
|
||||
if (!path) return;
|
||||
const adapter = app.vault.adapter;
|
||||
if (await adapter.exists(path)) return;
|
||||
await app.vault.createFolder(path);
|
||||
}
|
||||
486
src/tree-index.ts
Normal file
486
src/tree-index.ts
Normal file
|
|
@ -0,0 +1,486 @@
|
|||
import { TFile, TFolder, type App, type CachedMetadata } from "obsidian";
|
||||
import { ROOT_ID, type StashpadId, type TreeNode } from "./types";
|
||||
|
||||
/** Walk a Stashpad folder's TFolder subtree and return every .md file under
|
||||
* it. Iterative DFS rather than recursive to avoid a deep-recursion blow-up
|
||||
* if someone makes a pathological folder structure. Returns [] when the
|
||||
* folder doesn't exist yet (first run, or the user just renamed the
|
||||
* folder root). */
|
||||
function collectMarkdown(app: App, folderPath: string): TFile[] {
|
||||
const root = app.vault.getAbstractFileByPath(folderPath);
|
||||
if (!(root instanceof TFolder)) return [];
|
||||
const out: TFile[] = [];
|
||||
const stack: TFolder[] = [root];
|
||||
while (stack.length) {
|
||||
const f = stack.pop()!;
|
||||
for (const child of f.children) {
|
||||
if (child instanceof TFile) {
|
||||
if (child.extension === "md") out.push(child);
|
||||
} else if (child instanceof TFolder) {
|
||||
stack.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export class TreeIndex {
|
||||
private nodes = new Map<StashpadId, TreeNode>();
|
||||
private byPath = new Map<string, StashpadId>();
|
||||
private listeners = new Set<() => void>();
|
||||
private currentFolder: string | undefined;
|
||||
/** Optional override for sibling order. Returns the explicit order array
|
||||
* for a parent (empty if none); ids not in the array sort by created time. */
|
||||
private orderProvider: ((parentId: StashpadId) => string[]) | null = null;
|
||||
/** Synthetic-node carry-forward: nodes inserted via insertSynthetic() that haven't
|
||||
* yet been claimed by metadataCache. Persisted across rebuilds until cache catches up. */
|
||||
private synthetic = new Map<string /* path */, TreeNode>();
|
||||
/** Coalesce timer for the metadata-cache hook so a burst of events triggers one rebuild. */
|
||||
private coalesceTimer: number | null = null;
|
||||
|
||||
setOrderProvider(fn: ((parentId: StashpadId) => string[]) | null): void {
|
||||
this.orderProvider = fn;
|
||||
}
|
||||
|
||||
constructor(private app: App) {
|
||||
this.nodes.set(ROOT_ID, {
|
||||
id: ROOT_ID, parent: null, children: [], file: null, created: "",
|
||||
});
|
||||
}
|
||||
|
||||
onChange(fn: () => void): () => void {
|
||||
this.listeners.add(fn);
|
||||
return () => this.listeners.delete(fn);
|
||||
}
|
||||
|
||||
private emit(): void {
|
||||
for (const fn of this.listeners) fn();
|
||||
}
|
||||
|
||||
rebuild(folderPath?: string): void {
|
||||
if (folderPath !== undefined) this.currentFolder = folderPath;
|
||||
const folder = this.currentFolder;
|
||||
|
||||
// Snapshot the previous byPath/nodes so we can carry forward nodes whose
|
||||
// frontmatter hasn't been parsed yet (synthetic insertions, slow network drives).
|
||||
const prevByPath = this.byPath;
|
||||
const prevNodes = this.nodes;
|
||||
|
||||
this.nodes = new Map();
|
||||
this.byPath = new Map();
|
||||
this.nodes.set(ROOT_ID, { id: ROOT_ID, parent: null, children: [], file: null, created: "" });
|
||||
|
||||
// Folder-scoped enumeration: walk only the Stashpad folder's TFolder
|
||||
// subtree instead of `vault.getMarkdownFiles()` (which returns every
|
||||
// markdown file in the vault and then string-prefix-filters). In a
|
||||
// large vault with a small Stashpad this is a dramatic O(n_vault) →
|
||||
// O(n_folder) reduction.
|
||||
//
|
||||
// We still allow a `!folder` mode (currentFolder never set) for safety;
|
||||
// it falls back to the original vault-wide enumeration so the index
|
||||
// works during the brief window before the view's bootstrap finishes.
|
||||
const files: TFile[] = folder ? collectMarkdown(this.app, folder) : this.app.vault.getMarkdownFiles();
|
||||
|
||||
for (const f of files) {
|
||||
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter;
|
||||
const id = fm?.id as string | undefined;
|
||||
|
||||
// Carry-forward path: the file exists in the vault, but metadataCache hasn't
|
||||
// parsed it yet (no id). If we previously had a node for this path (synthetic
|
||||
// insert from createNoteUnder, or the previous rebuild), keep using it.
|
||||
if (!id) {
|
||||
const carriedId = prevByPath.get(f.path);
|
||||
const carried = carriedId ? prevNodes.get(carriedId) : undefined;
|
||||
const synthetic = this.synthetic.get(f.path);
|
||||
const node = carried ?? synthetic;
|
||||
if (node) {
|
||||
this.nodes.set(node.id, { ...node, children: [], file: f });
|
||||
this.byPath.set(f.path, node.id);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Once the cache has the id, the synthetic mapping is no longer needed.
|
||||
this.synthetic.delete(f.path);
|
||||
|
||||
if (id === ROOT_ID) {
|
||||
const root = this.nodes.get(ROOT_ID)!;
|
||||
root.file = f;
|
||||
root.created = (fm?.created as string) ?? "";
|
||||
this.byPath.set(f.path, ROOT_ID);
|
||||
continue;
|
||||
}
|
||||
const parent = (fm?.parent as string | null | undefined) ?? null;
|
||||
this.nodes.set(id, {
|
||||
id,
|
||||
parent: parent ?? ROOT_ID,
|
||||
children: [],
|
||||
file: f,
|
||||
created: (fm?.created as string) ?? "",
|
||||
});
|
||||
this.byPath.set(f.path, id);
|
||||
}
|
||||
|
||||
for (const node of this.nodes.values()) {
|
||||
if (node.id === ROOT_ID) continue;
|
||||
const parentId = node.parent ?? ROOT_ID;
|
||||
const parent = this.nodes.get(parentId) ?? this.nodes.get(ROOT_ID)!;
|
||||
parent.children.push(node.id);
|
||||
}
|
||||
|
||||
for (const node of this.nodes.values()) {
|
||||
// Default: sort by created (ascending — oldest first).
|
||||
node.children.sort((a, b) => {
|
||||
const na = this.nodes.get(a)!;
|
||||
const nb = this.nodes.get(b)!;
|
||||
return (na.created || "").localeCompare(nb.created || "");
|
||||
});
|
||||
// If an explicit order is provided for this parent, apply it: ids in the
|
||||
// order array come first (in the array's order); ids not in it stay where
|
||||
// they were (which is created-asc from the sort above).
|
||||
if (this.orderProvider) {
|
||||
const explicit = this.orderProvider(node.id);
|
||||
if (explicit.length > 0) {
|
||||
const positions = new Map<string, number>();
|
||||
explicit.forEach((id, i) => positions.set(id, i));
|
||||
node.children.sort((a, b) => {
|
||||
const pa = positions.has(a) ? positions.get(a)! : Infinity;
|
||||
const pb = positions.has(b) ? positions.get(b)! : Infinity;
|
||||
if (pa === pb) return 0;
|
||||
return pa - pb;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.emit();
|
||||
}
|
||||
|
||||
/** Insert a node into the tree without waiting for metadataCache to parse the file.
|
||||
* Used by createNoteUnder so a freshly-written note appears in the list immediately
|
||||
* on slow drives where the metadata cache parse is the bottleneck. */
|
||||
insertSynthetic(node: TreeNode): void {
|
||||
if (!node.file) return;
|
||||
const path = node.file.path;
|
||||
this.synthetic.set(path, node);
|
||||
this.nodes.set(node.id, node);
|
||||
this.byPath.set(path, node.id);
|
||||
const parentId = node.parent ?? ROOT_ID;
|
||||
const parent = this.nodes.get(parentId);
|
||||
if (parent && !parent.children.includes(node.id)) {
|
||||
parent.children.push(node.id);
|
||||
}
|
||||
this.emit();
|
||||
}
|
||||
|
||||
get(id: StashpadId): TreeNode | undefined {
|
||||
return this.nodes.get(id);
|
||||
}
|
||||
|
||||
getRoot(): TreeNode {
|
||||
return this.nodes.get(ROOT_ID)!;
|
||||
}
|
||||
|
||||
getChildren(id: StashpadId): TreeNode[] {
|
||||
const node = this.nodes.get(id);
|
||||
if (!node) return [];
|
||||
return node.children
|
||||
.map((cid) => this.nodes.get(cid))
|
||||
.filter((n): n is TreeNode => !!n);
|
||||
}
|
||||
|
||||
pathTo(id: StashpadId): TreeNode[] {
|
||||
const out: TreeNode[] = [];
|
||||
let cur = this.nodes.get(id);
|
||||
while (cur && cur.id !== ROOT_ID) {
|
||||
out.unshift(cur);
|
||||
cur = cur.parent ? this.nodes.get(cur.parent) : undefined;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
idForPath(path: string): StashpadId | undefined {
|
||||
return this.byPath.get(path);
|
||||
}
|
||||
|
||||
snapshot(): Record<string, { parent: string | null; path: string }> {
|
||||
const out: Record<string, { parent: string | null; path: string }> = {};
|
||||
for (const n of this.nodes.values()) {
|
||||
if (n.id === ROOT_ID || !n.file) continue;
|
||||
out[n.id] = { parent: n.parent === ROOT_ID ? null : n.parent, path: n.file.path };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
hookMetadataCache(onUpdate: () => void): () => void {
|
||||
const pathInFolder = (p: string | null | undefined): boolean => {
|
||||
const f = this.currentFolder;
|
||||
if (!f) return true; // early startup — accept everything
|
||||
if (typeof p !== "string") return false;
|
||||
return p === f || p.startsWith(f + "/");
|
||||
};
|
||||
|
||||
// Coalesce bursts: a single create can fire create + metadataCache.changed
|
||||
// back-to-back, plus multiple processFrontMatter calls during a batch op.
|
||||
// 16ms window collapses them into one onUpdate (render) call. Note the
|
||||
// tree state itself is mutated synchronously by the apply* methods —
|
||||
// only the visual update is debounced.
|
||||
let dirty = false;
|
||||
const scheduleUpdate = (): void => {
|
||||
dirty = true;
|
||||
if (this.coalesceTimer != null) return;
|
||||
this.coalesceTimer = window.setTimeout(() => {
|
||||
this.coalesceTimer = null;
|
||||
if (!dirty) return;
|
||||
dirty = false;
|
||||
this.emit();
|
||||
onUpdate();
|
||||
}, 16);
|
||||
};
|
||||
|
||||
const onChanged = (file: any): void => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (!pathInFolder(file.path)) return;
|
||||
if (file.extension !== "md") return;
|
||||
if (this.applyChange(file)) scheduleUpdate();
|
||||
};
|
||||
const onCreate = (file: any): void => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
if (!pathInFolder(file.path)) return;
|
||||
if (file.extension !== "md") return;
|
||||
if (this.applyChange(file)) scheduleUpdate();
|
||||
};
|
||||
const onDelete = (file: any): void => {
|
||||
const path = file?.path;
|
||||
if (typeof path !== "string") return;
|
||||
if (!pathInFolder(path)) return;
|
||||
if (this.applyDelete(path)) scheduleUpdate();
|
||||
};
|
||||
const onRename = (file: any, oldPath: any): void => {
|
||||
if (!(file instanceof TFile)) return;
|
||||
const oldP = typeof oldPath === "string" ? oldPath : null;
|
||||
if (!pathInFolder(file.path) && !pathInFolder(oldP)) return;
|
||||
if (this.applyRename(file, oldP ?? "")) scheduleUpdate();
|
||||
};
|
||||
|
||||
(this.app.metadataCache as any).on("changed", onChanged);
|
||||
(this.app.vault as any).on("create", onCreate);
|
||||
(this.app.vault as any).on("delete", onDelete);
|
||||
(this.app.vault as any).on("rename", onRename);
|
||||
return () => {
|
||||
(this.app.metadataCache as any).off("changed", onChanged);
|
||||
(this.app.vault as any).off("create", onCreate);
|
||||
(this.app.vault as any).off("delete", onDelete);
|
||||
(this.app.vault as any).off("rename", onRename);
|
||||
if (this.coalesceTimer != null) { window.clearTimeout(this.coalesceTimer); this.coalesceTimer = null; }
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Incremental update path
|
||||
//
|
||||
// Before these existed, every vault event triggered a full rebuild —
|
||||
// scanning the whole folder, allocating a fresh nodes/byPath Map, and
|
||||
// re-sorting every parent's children. For a single-file change in a
|
||||
// 500-note Stashpad that was O(500) work for an O(1) event.
|
||||
//
|
||||
// applyChange/applyDelete/applyRename instead patch the affected nodes
|
||||
// and re-sort only the parents that actually moved. Safety net: if any
|
||||
// case looks even slightly weird (unknown parent, id collision across
|
||||
// paths, missing entries), we fall back to a full rebuild() — which is
|
||||
// now folder-scoped and cheap. So the incremental path is always a
|
||||
// pure optimization; bugs degrade to "full rebuild" rather than
|
||||
// "stale tree."
|
||||
//
|
||||
// All three methods return `true` when the tree changed and an emit
|
||||
// is warranted.
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/** Apply a single file create/modify event. Returns true if the tree
|
||||
* changed (caller should schedule onUpdate). */
|
||||
private applyChange(file: TFile): boolean {
|
||||
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
const id = fm?.id as string | undefined;
|
||||
const oldId = this.byPath.get(file.path);
|
||||
|
||||
// Metadata cache hasn't parsed this file yet (no id in frontmatter).
|
||||
// If we already have a synthetic / previous node for the path, just
|
||||
// refresh its file reference; the metadata-cache "changed" event will
|
||||
// fire again once the cache catches up.
|
||||
if (!id) {
|
||||
if (oldId) {
|
||||
const node = this.nodes.get(oldId);
|
||||
if (node && node.file !== file) {
|
||||
node.file = file;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Synthetic mapping no longer needed once the cache has the real id.
|
||||
this.synthetic.delete(file.path);
|
||||
|
||||
// Self-declared root note. Update root metadata in place; root never
|
||||
// has siblings to re-sort.
|
||||
if (id === ROOT_ID) {
|
||||
const root = this.nodes.get(ROOT_ID)!;
|
||||
const created = (fm?.created as string) ?? "";
|
||||
let changed = false;
|
||||
if (root.file !== file) { root.file = file; changed = true; }
|
||||
if (root.created !== created) { root.created = created; changed = true; }
|
||||
if (this.byPath.get(file.path) !== ROOT_ID) {
|
||||
this.byPath.set(file.path, ROOT_ID);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
const parentId = ((fm?.parent as string | null | undefined) ?? ROOT_ID) as StashpadId;
|
||||
const created = (fm?.created as string) ?? "";
|
||||
|
||||
// Safety net: if the declared parent isn't ROOT and isn't in the
|
||||
// tree yet (or our path<->id map is in a weird state), fall back to
|
||||
// a full rebuild. The folder-scoped rebuild is cheap; correctness
|
||||
// wins over a few hundred microseconds.
|
||||
const parentKnown = parentId === ROOT_ID || this.nodes.has(parentId);
|
||||
if (!parentKnown) {
|
||||
this.rebuild();
|
||||
return true;
|
||||
}
|
||||
// Detect path/id desync (e.g. someone rewrote the frontmatter id by
|
||||
// hand): the path used to map to a different id. Cleanest recovery
|
||||
// is a full rebuild.
|
||||
if (oldId && oldId !== id) {
|
||||
this.rebuild();
|
||||
return true;
|
||||
}
|
||||
|
||||
const existing = this.nodes.get(id);
|
||||
if (!existing) {
|
||||
// Brand-new node.
|
||||
const node: TreeNode = { id, parent: parentId, children: [], file, created };
|
||||
this.nodes.set(id, node);
|
||||
this.byPath.set(file.path, id);
|
||||
this.attachToParent(node);
|
||||
this.resortChildrenOf(parentId);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Existing node — diff fields and re-attach/re-sort only as needed.
|
||||
let changed = false;
|
||||
if (existing.file !== file) { existing.file = file; changed = true; }
|
||||
if (existing.created !== created) {
|
||||
existing.created = created;
|
||||
this.resortChildrenOf(existing.parent ?? ROOT_ID);
|
||||
changed = true;
|
||||
}
|
||||
if (existing.parent !== parentId) {
|
||||
const oldParentId = existing.parent ?? ROOT_ID;
|
||||
this.detachFromParent(existing);
|
||||
existing.parent = parentId;
|
||||
this.attachToParent(existing);
|
||||
this.resortChildrenOf(oldParentId);
|
||||
this.resortChildrenOf(parentId);
|
||||
changed = true;
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
/** Apply a file delete. Reassigns the deleted node's children to ROOT
|
||||
* (mirroring the full-rebuild behavior when a parent file is missing). */
|
||||
private applyDelete(path: string): boolean {
|
||||
const id = this.byPath.get(path);
|
||||
if (!id) return false;
|
||||
const node = this.nodes.get(id);
|
||||
if (!node) {
|
||||
this.byPath.delete(path);
|
||||
this.synthetic.delete(path);
|
||||
return true;
|
||||
}
|
||||
this.detachFromParent(node);
|
||||
// Re-parent any descendants to ROOT so they don't dangle. We don't
|
||||
// touch their frontmatter — Stashpad's adoptNote / integrity-check
|
||||
// command handles that side. This just keeps the in-memory tree
|
||||
// consistent until the user runs a fixup.
|
||||
const root = this.nodes.get(ROOT_ID)!;
|
||||
for (const cid of node.children) {
|
||||
const child = this.nodes.get(cid);
|
||||
if (child) {
|
||||
child.parent = ROOT_ID;
|
||||
if (!root.children.includes(cid)) root.children.push(cid);
|
||||
}
|
||||
}
|
||||
if (node.children.length > 0) this.resortChildrenOf(ROOT_ID);
|
||||
this.nodes.delete(id);
|
||||
this.byPath.delete(path);
|
||||
this.synthetic.delete(path);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Apply a vault rename. Three cases:
|
||||
* - moved out of folder → treat as delete
|
||||
* - moved into folder → treat as create (delegated to applyChange)
|
||||
* - renamed within folder → just remap byPath; frontmatter unaffected */
|
||||
private applyRename(file: TFile, oldPath: string): boolean {
|
||||
const folder = this.currentFolder;
|
||||
const wasIn = !folder || oldPath === folder || oldPath.startsWith(folder + "/");
|
||||
const isIn = !folder || file.path === folder || file.path.startsWith(folder + "/");
|
||||
if (!wasIn && !isIn) return false;
|
||||
if (wasIn && !isIn) return this.applyDelete(oldPath);
|
||||
if (!wasIn && isIn) return this.applyChange(file);
|
||||
// Rename within folder.
|
||||
const id = this.byPath.get(oldPath);
|
||||
if (!id) {
|
||||
// We never had this path indexed — fall through to a normal apply.
|
||||
return this.applyChange(file);
|
||||
}
|
||||
this.byPath.delete(oldPath);
|
||||
this.byPath.set(file.path, id);
|
||||
const node = this.nodes.get(id);
|
||||
if (node) node.file = file;
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- attach / detach / sort helpers ---
|
||||
|
||||
private detachFromParent(node: TreeNode): void {
|
||||
const parent = this.nodes.get(node.parent ?? ROOT_ID);
|
||||
if (!parent) return;
|
||||
const i = parent.children.indexOf(node.id);
|
||||
if (i >= 0) parent.children.splice(i, 1);
|
||||
}
|
||||
|
||||
private attachToParent(node: TreeNode): void {
|
||||
// If the declared parent doesn't exist in the tree, fall back to ROOT
|
||||
// (same orphan-handling as rebuild()'s second pass).
|
||||
let parent = this.nodes.get(node.parent ?? ROOT_ID);
|
||||
if (!parent) parent = this.nodes.get(ROOT_ID)!;
|
||||
if (!parent.children.includes(node.id)) parent.children.push(node.id);
|
||||
}
|
||||
|
||||
/** Re-sort a single parent's children using the same default + orderProvider
|
||||
* pipeline as rebuild(). Pulled out so incremental updates can re-sort
|
||||
* exactly one parent instead of every parent in the tree. */
|
||||
private resortChildrenOf(parentId: StashpadId): void {
|
||||
const p = this.nodes.get(parentId);
|
||||
if (!p) return;
|
||||
p.children.sort((a, b) => {
|
||||
const na = this.nodes.get(a)!;
|
||||
const nb = this.nodes.get(b)!;
|
||||
return (na.created || "").localeCompare(nb.created || "");
|
||||
});
|
||||
if (this.orderProvider) {
|
||||
const explicit = this.orderProvider(parentId);
|
||||
if (explicit.length > 0) {
|
||||
const positions = new Map<string, number>();
|
||||
explicit.forEach((id, i) => positions.set(id, i));
|
||||
p.children.sort((a, b) => {
|
||||
const pa = positions.has(a) ? positions.get(a)! : Infinity;
|
||||
const pb = positions.has(b) ? positions.get(b)! : Infinity;
|
||||
if (pa === pb) return 0;
|
||||
return pa - pb;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
63
src/types.ts
Normal file
63
src/types.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { TFile } from "obsidian";
|
||||
|
||||
export const STASHPAD_VIEW_TYPE = "stashpad-view";
|
||||
export const ROOT_ID = "__root__";
|
||||
|
||||
export type StashpadId = string;
|
||||
|
||||
export interface NoteFrontmatter {
|
||||
id: StashpadId;
|
||||
parent: StashpadId | null;
|
||||
created: string;
|
||||
attachments?: string[];
|
||||
tags?: string[];
|
||||
/** Optional hex color (e.g. "#E07A78") that tints the row's swatch, border,
|
||||
* and child-count arrow. Stored verbatim in frontmatter. */
|
||||
color?: string | null;
|
||||
}
|
||||
|
||||
export interface TreeNode {
|
||||
id: StashpadId;
|
||||
parent: StashpadId | null;
|
||||
children: StashpadId[];
|
||||
file: TFile | null;
|
||||
created: string;
|
||||
}
|
||||
|
||||
export type TimeFilter = "all" | "year" | "month" | "week" | "day";
|
||||
|
||||
/** Per-folder view mode (settings.viewModes keyed by folder path).
|
||||
* - "nested" (default): tree, immediate children of focus.
|
||||
* - "flat": all descendants of focus, flat, sorted by the current sort mode.
|
||||
* - "everything": all descendants of focus PLUS every non-Stashpad file in
|
||||
* the Stashpad folder, interleaved by created/ctime. Non-Stashpad files
|
||||
* are always folder-wide (they don't belong to any note).
|
||||
*
|
||||
* Drag-reorder and tree-mutation commands only operate in "nested" mode;
|
||||
* in flat/everything the list is a synthesized view and direct
|
||||
* position/parent changes would have no meaningful target. */
|
||||
export type ViewMode = "nested" | "flat" | "everything";
|
||||
|
||||
export interface ViewConfigState {
|
||||
focusId: StashpadId;
|
||||
timeFilter: TimeFilter;
|
||||
}
|
||||
|
||||
export type LogEventType =
|
||||
| "create" | "delete" | "missing" | "parent_change" | "rename" | "reorder"
|
||||
| "complete" | "uncomplete"
|
||||
| "stash_export" | "stash_import"
|
||||
| "attachment_add" | "attachment_remove"
|
||||
| "palette_color_add" | "palette_color_remove";
|
||||
|
||||
export interface LogEvent {
|
||||
ts: string;
|
||||
type: LogEventType;
|
||||
id: StashpadId;
|
||||
payload?: Record<string, unknown>;
|
||||
/** Display name of whoever performed the action. Stamped automatically
|
||||
* by StashpadLog.append() from the plugin's authorName setting; older
|
||||
* log lines may lack this field, in which case readers should treat
|
||||
* it as unknown. */
|
||||
author?: string;
|
||||
}
|
||||
39
src/undo-stack.ts
Normal file
39
src/undo-stack.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
export interface UndoAction {
|
||||
label: string;
|
||||
undo: () => Promise<void>;
|
||||
redo: () => Promise<void>;
|
||||
}
|
||||
|
||||
export class UndoStack {
|
||||
private undoStack: UndoAction[] = [];
|
||||
private redoStack: UndoAction[] = [];
|
||||
private cap = 30;
|
||||
|
||||
push(action: UndoAction): void {
|
||||
this.undoStack.push(action);
|
||||
this.redoStack.length = 0;
|
||||
while (this.undoStack.length > this.cap) this.undoStack.shift();
|
||||
}
|
||||
|
||||
async undo(): Promise<UndoAction | null> {
|
||||
const a = this.undoStack.pop();
|
||||
if (!a) return null;
|
||||
try { await a.undo(); } catch (e) { console.error("Stashpad: undo failed", e); throw e; }
|
||||
this.redoStack.push(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
async redo(): Promise<UndoAction | null> {
|
||||
const a = this.redoStack.pop();
|
||||
if (!a) return null;
|
||||
try { await a.redo(); } catch (e) { console.error("Stashpad: redo failed", e); throw e; }
|
||||
this.undoStack.push(a);
|
||||
return a;
|
||||
}
|
||||
|
||||
peekUndoLabel(): string | null { return this.undoStack[this.undoStack.length - 1]?.label ?? null; }
|
||||
peekRedoLabel(): string | null { return this.redoStack[this.redoStack.length - 1]?.label ?? null; }
|
||||
canUndo(): boolean { return this.undoStack.length > 0; }
|
||||
canRedo(): boolean { return this.redoStack.length > 0; }
|
||||
clear(): void { this.undoStack = []; this.redoStack = []; }
|
||||
}
|
||||
6386
src/view.ts
Normal file
6386
src/view.ts
Normal file
File diff suppressed because it is too large
Load diff
320
styles.css
320
styles.css
|
|
@ -328,6 +328,11 @@
|
|||
border-color: var(--interactive-accent);
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
.stashpad-sort-btn.is-disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.stashpad-sort-btn.is-disabled:hover { background: var(--background-secondary); }
|
||||
.stashpad-sort-icon { display: inline-flex; align-items: center; }
|
||||
.stashpad-sort-icon .svg-icon { width: 14px; height: 14px; }
|
||||
.stashpad-sort-label {
|
||||
|
|
@ -367,6 +372,288 @@
|
|||
}
|
||||
.stashpad-sort-popover-label { white-space: nowrap; }
|
||||
|
||||
/* View dropdown — same shape as Sort but each row is two-line (label + a
|
||||
short description) so the three modes read clearly at a glance. */
|
||||
.stashpad-view-btn {
|
||||
flex: 0 0 auto;
|
||||
width: max-content;
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
font-size: var(--font-ui-smaller);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stashpad-view-btn:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-view-btn.is-active {
|
||||
border-color: var(--interactive-accent);
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
.stashpad-view-icon { display: inline-flex; align-items: center; }
|
||||
.stashpad-view-icon .svg-icon { width: 14px; height: 14px; }
|
||||
.stashpad-view-label { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.stashpad-view.is-mobile .stashpad-view-btn {
|
||||
height: 28px;
|
||||
padding: 0 6px;
|
||||
}
|
||||
|
||||
.stashpad-view-popover {
|
||||
position: fixed;
|
||||
z-index: var(--layer-popover, 30);
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
padding: 4px 0;
|
||||
font-size: var(--font-ui-small);
|
||||
/* No max-height — the menu always expands fully so the user can see
|
||||
every mode + filter at once. overflow-y is a no-op without a
|
||||
height constraint, but kept declared as a safety net for any
|
||||
future case where the popover needs to clamp (e.g. tiny popout
|
||||
window on desktop). */
|
||||
overflow-y: auto;
|
||||
}
|
||||
/* Mobile: hide per-row descriptions to keep popovers compact. Applies
|
||||
to both the standalone view popover (.stashpad-view-popover.is-mobile)
|
||||
AND the body of any section rendered inside the combined-filters
|
||||
accordion (.stashpad-mobile-filters-popover wraps every section). */
|
||||
.stashpad-view-popover.is-mobile .stashpad-view-popover-desc,
|
||||
.stashpad-mobile-filters-popover .stashpad-view-popover-desc {
|
||||
display: none;
|
||||
}
|
||||
.stashpad-view-popover.is-mobile .stashpad-view-popover-row,
|
||||
.stashpad-mobile-filters-popover .stashpad-view-popover-row {
|
||||
padding: 6px 12px;
|
||||
}
|
||||
.stashpad-view-popover-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-view-popover-row:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-view-popover-row.is-active .stashpad-view-popover-label {
|
||||
color: var(--interactive-accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.stashpad-view-popover-main { display: flex; align-items: center; gap: 8px; }
|
||||
.stashpad-view-popover-label { white-space: nowrap; }
|
||||
.stashpad-view-popover-desc {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-muted);
|
||||
white-space: normal;
|
||||
max-width: 280px;
|
||||
}
|
||||
.stashpad-view-popover-divider {
|
||||
height: 1px;
|
||||
background: var(--background-modifier-border);
|
||||
margin: 4px 0;
|
||||
}
|
||||
/* Toggle row: 2-column grid keeps the checkbox aligned with the label
|
||||
in the first row and the description tucked under the label in the
|
||||
second row. Cleaner than the previous flex-wrap which depended on
|
||||
browser-default checkbox widths matching the desc's left padding. */
|
||||
.stashpad-view-popover-toggle {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
column-gap: 8px;
|
||||
row-gap: 2px;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
.stashpad-view-popover-toggle input[type="checkbox"] {
|
||||
/* Use transform: scale() instead of explicit width/height to shrink
|
||||
the checkbox visually. Chromium draws the native checkmark glyph
|
||||
at fixed pixel sizes; clamping the box smaller crops the glyph and
|
||||
visually decentres the check. Scaling lets the glyph shrink in
|
||||
proportion with the box so the check stays centred. transform-origin
|
||||
left keeps the small box flush with the left edge of the grid
|
||||
column. */
|
||||
margin: 0;
|
||||
transform: scale(0.85);
|
||||
transform-origin: left center;
|
||||
grid-row: 1;
|
||||
grid-column: 1;
|
||||
justify-self: start;
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-view-popover-toggle .stashpad-view-popover-main {
|
||||
grid-row: 1;
|
||||
grid-column: 2;
|
||||
}
|
||||
.stashpad-view-popover-toggle .stashpad-view-popover-desc {
|
||||
grid-row: 2;
|
||||
grid-column: 2;
|
||||
padding-left: 0;
|
||||
}
|
||||
.stashpad-view-popover-toggle.is-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Mobile combined-filters button: a single sliders icon that replaces
|
||||
the four desktop dropdown buttons (tag/color/sort/view) on mobile.
|
||||
Picks up an accent border when any of the underlying filters / view
|
||||
options are in a non-default state. */
|
||||
.stashpad-mobile-filters-btn {
|
||||
flex: 0 0 auto;
|
||||
/* Push to the far right of the header bar — auto-margin in a flex
|
||||
row absorbs all remaining space on the left of this element, so
|
||||
the button sits hard against the right edge regardless of what
|
||||
else (folder button, etc.) is rendered to its left. */
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 28px;
|
||||
width: 32px;
|
||||
padding: 0;
|
||||
border-radius: 6px;
|
||||
background: var(--background-secondary);
|
||||
color: var(--text-normal);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.stashpad-mobile-filters-btn:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-mobile-filters-btn.is-active {
|
||||
border-color: var(--interactive-accent);
|
||||
color: var(--interactive-accent);
|
||||
}
|
||||
.stashpad-mobile-filters-icon { display: inline-flex; align-items: center; }
|
||||
.stashpad-mobile-filters-icon .svg-icon { width: 16px; height: 16px; }
|
||||
|
||||
/* Accordion popover. Four sections stacked vertically; one expanded at
|
||||
a time. Header chevron flips between right (collapsed) and down
|
||||
(expanded). Bodies host the same row markup the desktop popovers
|
||||
use — see populate*MenuBody methods. */
|
||||
.stashpad-mobile-filters-popover {
|
||||
position: fixed;
|
||||
z-index: var(--layer-popover, 30);
|
||||
background: var(--background-primary);
|
||||
border: 1px solid var(--background-modifier-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.18);
|
||||
padding: 4px 0;
|
||||
font-size: var(--font-ui-small);
|
||||
overflow-y: auto;
|
||||
/* Cap height so a fully expanded section with many rows can scroll
|
||||
instead of clipping below the viewport. */
|
||||
max-height: calc(100vh - 80px);
|
||||
}
|
||||
.stashpad-mobile-filters-section + .stashpad-mobile-filters-section {
|
||||
border-top: 1px solid var(--background-modifier-border);
|
||||
}
|
||||
.stashpad-mobile-filters-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.stashpad-mobile-filters-header:hover { background: var(--background-modifier-hover); }
|
||||
.stashpad-mobile-filters-chev {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-mobile-filters-chev .svg-icon { width: 14px; height: 14px; }
|
||||
.stashpad-mobile-filters-title { font-weight: 600; }
|
||||
.stashpad-mobile-filters-summary {
|
||||
margin-left: auto;
|
||||
color: var(--text-faint);
|
||||
font-size: var(--font-ui-smaller);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 50%;
|
||||
}
|
||||
.stashpad-mobile-filters-body {
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
.stashpad-mobile-filters-note {
|
||||
padding: 8px 16px;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-ui-smaller);
|
||||
}
|
||||
|
||||
/* Per-row breadcrumb shown in Flat / Everything modes. Sits above the
|
||||
note body, muted by default so it doesn't compete with the body text.
|
||||
Segments are clickable to focus into that ancestor. */
|
||||
.stashpad-row-breadcrumb {
|
||||
font-size: var(--font-ui-smaller);
|
||||
color: var(--text-faint);
|
||||
margin-bottom: 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.stashpad-row-breadcrumb-seg {
|
||||
cursor: pointer;
|
||||
}
|
||||
.stashpad-row-breadcrumb-seg:hover {
|
||||
color: var(--text-normal);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.stashpad-row-breadcrumb-sep {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Non-Stashpad file row in Everything mode — intentionally compact and
|
||||
visually distinct from note rows so users see it's a different kind
|
||||
of item (decorative click-to-open, not part of the selection model). */
|
||||
.stashpad-file-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 12px;
|
||||
border-left: 3px solid transparent;
|
||||
cursor: pointer;
|
||||
font-size: var(--font-ui-small);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
.stashpad-file-row:hover {
|
||||
background: var(--background-modifier-hover);
|
||||
color: var(--text-normal);
|
||||
}
|
||||
.stashpad-file-meta {
|
||||
flex: 0 0 auto;
|
||||
min-width: 70px;
|
||||
}
|
||||
.stashpad-file-time {
|
||||
font-variant-numeric: tabular-nums;
|
||||
color: var(--text-faint);
|
||||
}
|
||||
.stashpad-file-body {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.stashpad-file-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.stashpad-file-ext {
|
||||
flex: 0 0 auto;
|
||||
font-size: var(--font-ui-smaller);
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
background: var(--background-modifier-border);
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.stashpad-color-filter-popover {
|
||||
position: fixed;
|
||||
z-index: var(--layer-popover, 30);
|
||||
|
|
@ -394,8 +681,11 @@
|
|||
.stashpad-time-filter-btns { display: none; }
|
||||
.stashpad-time-filter-select { display: block; }
|
||||
}
|
||||
.stashpad-view.is-mobile .stashpad-time-filter-btns { display: none; }
|
||||
.stashpad-view.is-mobile .stashpad-time-filter-select { display: block; }
|
||||
/* Mobile: the time filter moves into the combined-filters accordion.
|
||||
Hide both the button row AND the fallback select here — the accordion's
|
||||
Time section provides the UI. */
|
||||
.stashpad-view.is-mobile .stashpad-time-filter-btns,
|
||||
.stashpad-view.is-mobile .stashpad-time-filter-select { display: none; }
|
||||
|
||||
.stashpad-breadcrumb {
|
||||
font-size: var(--font-ui-small);
|
||||
|
|
@ -568,6 +858,32 @@
|
|||
.stashpad-note.has-color {
|
||||
border-color: var(--stashpad-note-color);
|
||||
}
|
||||
|
||||
/* Inherited-color side stripe: rendered on rows whose nearest colored
|
||||
ancestor is some depth above them. A short pseudo-element down the
|
||||
left edge tinted with the ancestor's hex, faded by depth (~18% off
|
||||
per level) via color-mix. Visually marks "this row belongs to a
|
||||
colored branch" without overwhelming the row's own chrome. Hidden
|
||||
beneath the row's normal border by being inset 1px from the edge.
|
||||
color-mix is supported on Electron 113+ (current Obsidian). */
|
||||
.stashpad-note.has-inherited-color {
|
||||
position: relative;
|
||||
}
|
||||
.stashpad-note.has-inherited-color::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -1px;
|
||||
top: 4px;
|
||||
bottom: 4px;
|
||||
width: 3px;
|
||||
border-radius: 2px;
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--stashpad-inherited-color) calc(100% - var(--stashpad-inherited-depth, 0) * 18%),
|
||||
transparent
|
||||
);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* When the row is selected, the colored border thickens so the selection
|
||||
state reads as a single, unified ring — no double-line from the default
|
||||
selected accent or the is-cursor inset shadow. */
|
||||
|
|
|
|||
15
tsconfig.json
Normal file
15
tsconfig.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ES2020",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noImplicitAny": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"lib": ["DOM", "ES2020"],
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src/**/*.ts"]
|
||||
}
|
||||
Loading…
Reference in a new issue