mirror of
https://github.com/grub-basket/SP.git
synced 2026-07-22 07:46:27 +00:00
0.82.4: faster big lists (lazy rendering), import refinements, navigation polish
This commit is contained in:
parent
e20080d031
commit
fb44654bdd
16 changed files with 1069 additions and 166 deletions
133
main.js
133
main.js
File diff suppressed because one or more lines are too long
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"id": "stashpad",
|
||||
"name": "Stashpad",
|
||||
"version": "0.79.15",
|
||||
"version": "0.82.4",
|
||||
"minAppVersion": "1.7.0",
|
||||
"description": "Chat-style nested-notes view: rapid capture, outliner navigation, in-place editing.",
|
||||
"author": "Human",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "stashpad-obsidian",
|
||||
"version": "0.79.15",
|
||||
"version": "0.82.4",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "node esbuild.config.mjs",
|
||||
|
|
|
|||
443
pnpm-lock.yaml
Normal file
443
pnpm-lock.yaml
Normal file
|
|
@ -0,0 +1,443 @@
|
|||
lockfileVersion: '9.0'
|
||||
|
||||
settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
devDependencies:
|
||||
'@types/node':
|
||||
specifier: ^20.10.0
|
||||
version: 20.19.41
|
||||
esbuild:
|
||||
specifier: ^0.21.0
|
||||
version: 0.21.5
|
||||
obsidian:
|
||||
specifier: latest
|
||||
version: 1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6)
|
||||
typescript:
|
||||
specifier: ^5.4.0
|
||||
version: 5.9.3
|
||||
|
||||
packages:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
resolution: {integrity: sha512-MwBHVK60IiIHDcoMet78lxt6iw5gJOGSbNbOIVBHWVXIH4/Nq1+GQgLLGgI1KlnN86WDXsPudVaqYHKBIx7Eyw==}
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
resolution: {integrity: sha512-qiS0z1bKs5WOvHIAC0Cybmv4AJSkAXgX5aD6Mqd2epSLlVJsQl8NG23jCVouIgkh4All/mrbdsf2UOLFnJw0tw==}
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [android]
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==}
|
||||
engines: {node: '>=12'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2':
|
||||
resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
resolution: {integrity: sha512-VjFgDF/eB+Aklcy15TtOTLQeMjTo07k7KAjql8OK5Dirr7a6sJY4T1uVBDuTVG9VEmn1uUsohOpYnVfgC6/jyw==}
|
||||
|
||||
'@types/estree@1.0.9':
|
||||
resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
resolution: {integrity: sha512-ECymXOukMnOoVkC2bb1Vc/w/836DXncOg5m8Xj1RH7xSHZJWNYY6Zh7EH477vcnD5egKNNfy2RpNOmuChhFPgQ==}
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
resolution: {integrity: sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw==}
|
||||
|
||||
core-util-is@1.0.3:
|
||||
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
|
||||
|
||||
crelt@1.0.6:
|
||||
resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==}
|
||||
|
||||
esbuild@0.21.5:
|
||||
resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==}
|
||||
engines: {node: '>=12'}
|
||||
hasBin: true
|
||||
|
||||
immediate@3.0.6:
|
||||
resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==}
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
isarray@1.0.0:
|
||||
resolution: {integrity: sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==}
|
||||
|
||||
jszip@3.10.1:
|
||||
resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==}
|
||||
|
||||
lie@3.3.0:
|
||||
resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==}
|
||||
|
||||
moment@2.29.4:
|
||||
resolution: {integrity: sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==}
|
||||
|
||||
obsidian@1.12.3:
|
||||
resolution: {integrity: sha512-HxWqe763dOqzXjnNiHmAJTRERN8KILBSqxDSEqbeSr7W8R8Jxezzbca+nz1LiiqXnMpM8lV2jzAezw3CZ4xNUw==}
|
||||
peerDependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
|
||||
pako@1.0.11:
|
||||
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
resolution: {integrity: sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==}
|
||||
|
||||
safe-buffer@5.1.2:
|
||||
resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==}
|
||||
|
||||
setimmediate@1.0.5:
|
||||
resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
resolution: {integrity: sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==}
|
||||
|
||||
style-mod@4.1.3:
|
||||
resolution: {integrity: sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
undici-types@6.21.0:
|
||||
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
|
||||
|
||||
util-deprecate@1.0.2:
|
||||
resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
|
||||
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@codemirror/state@6.5.0':
|
||||
dependencies:
|
||||
'@marijn/find-cluster-break': 1.0.2
|
||||
|
||||
'@codemirror/view@6.38.6':
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
crelt: 1.0.6
|
||||
style-mod: 4.1.3
|
||||
w3c-keyname: 2.2.8
|
||||
|
||||
'@esbuild/aix-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/android-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/darwin-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/freebsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-arm@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-loong64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-mips64el@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-ppc64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-riscv64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-s390x@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/linux-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/netbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/openbsd-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/sunos-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-arm64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-ia32@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@esbuild/win32-x64@0.21.5':
|
||||
optional: true
|
||||
|
||||
'@marijn/find-cluster-break@1.0.2': {}
|
||||
|
||||
'@types/codemirror@5.60.8':
|
||||
dependencies:
|
||||
'@types/tern': 0.23.9
|
||||
|
||||
'@types/estree@1.0.9': {}
|
||||
|
||||
'@types/node@20.19.41':
|
||||
dependencies:
|
||||
undici-types: 6.21.0
|
||||
|
||||
'@types/tern@0.23.9':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.9
|
||||
|
||||
core-util-is@1.0.3: {}
|
||||
|
||||
crelt@1.0.6: {}
|
||||
|
||||
esbuild@0.21.5:
|
||||
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
|
||||
|
||||
immediate@3.0.6: {}
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
isarray@1.0.0: {}
|
||||
|
||||
jszip@3.10.1:
|
||||
dependencies:
|
||||
lie: 3.3.0
|
||||
pako: 1.0.11
|
||||
readable-stream: 2.3.8
|
||||
setimmediate: 1.0.5
|
||||
|
||||
lie@3.3.0:
|
||||
dependencies:
|
||||
immediate: 3.0.6
|
||||
|
||||
moment@2.29.4: {}
|
||||
|
||||
obsidian@1.12.3(@codemirror/state@6.5.0)(@codemirror/view@6.38.6):
|
||||
dependencies:
|
||||
'@codemirror/state': 6.5.0
|
||||
'@codemirror/view': 6.38.6
|
||||
'@types/codemirror': 5.60.8
|
||||
moment: 2.29.4
|
||||
|
||||
pako@1.0.11: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
readable-stream@2.3.8:
|
||||
dependencies:
|
||||
core-util-is: 1.0.3
|
||||
inherits: 2.0.4
|
||||
isarray: 1.0.0
|
||||
process-nextick-args: 2.0.1
|
||||
safe-buffer: 5.1.2
|
||||
string_decoder: 1.1.1
|
||||
util-deprecate: 1.0.2
|
||||
|
||||
safe-buffer@5.1.2: {}
|
||||
|
||||
setimmediate@1.0.5: {}
|
||||
|
||||
string_decoder@1.1.1:
|
||||
dependencies:
|
||||
safe-buffer: 5.1.2
|
||||
|
||||
style-mod@4.1.3: {}
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
undici-types@6.21.0: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Allow esbuild's postinstall script (downloads its native binary). pnpm 11+
|
||||
# blocks all install scripts by default as a security policy; this opts
|
||||
# esbuild in. Without it, `pnpm run build` fails before invoking the build
|
||||
# command because pnpm's deps-status preamble sees the blocked script.
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
51
release-notes/0.82.4.md
Normal file
51
release-notes/0.82.4.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# 0.82.4 — Faster big lists, import refinements, navigation polish
|
||||
|
||||
## Performance: big lists load far faster (0.81–0.82)
|
||||
|
||||
The headline change. Opening a folder used to read and render *every*
|
||||
note's body up front; on a large folder (or a network drive) that was the
|
||||
dominant cost.
|
||||
|
||||
- **Lazy body rendering.** Rows now render their body only when they
|
||||
scroll near the viewport. A cheap title placeholder shows instantly
|
||||
(the title comes from the filename — no file read), and the full body
|
||||
fills in on demand. Already-rendered rows still paint instantly from
|
||||
cache. In practice this cut per-row read time ~40× and reads-per-open
|
||||
from "every note" to "roughly what's on screen."
|
||||
- **Look-ahead margin** pre-renders a screenful above/below so scrolling
|
||||
and arrow-navigation rarely catch a placeholder mid-fill.
|
||||
- **Opt-in performance profiler.** A Diagnostics toggle records render /
|
||||
read / write timing; the *Dump performance profile* command copies a
|
||||
Markdown table you can inspect. Zero overhead when off.
|
||||
|
||||
## Imports: safer defaults + fixes (0.79.16–0.79.21, 0.80.1, 0.80.5)
|
||||
|
||||
- **Auto-import is now off by default.** Dropping files only imports
|
||||
automatically once you enable it in settings. The *Import file(s) into
|
||||
Stashpad…* command works either way — it writes the chosen files and
|
||||
imports them directly, listing the current folder first.
|
||||
- **Attachments are embedded**, not just linked (`![[file]]`), so they
|
||||
preview inline; the `attachments` frontmatter is stored as proper
|
||||
internal links.
|
||||
- **Created/modified times are preserved** on import (falling back to the
|
||||
file's own timestamps) instead of being stamped to the import time.
|
||||
- **Slow-drive + mobile fixes.** Stashpad no longer re-imports notes it
|
||||
just created (a network-drive race that could re-home a note to Home),
|
||||
and an editing/rename loop that could endlessly re-import a note on
|
||||
mobile is fixed. Rebootstrap never bumps `created`/`modified` or adds
|
||||
contributors. An archive-move conflict guard prevents losing an original.
|
||||
- `_archive` is excluded from the find / move / destination pickers (and
|
||||
Obsidian's own search), so archived originals don't clutter results.
|
||||
- File-manager labels are platform-correct ("Show in File Explorer" on
|
||||
Windows, "Finder" on macOS).
|
||||
|
||||
## Navigation polish (0.80.2–0.80.4, 0.82.4)
|
||||
|
||||
- **Jump to top / bottom** of the list — new commands with default
|
||||
`Home` / `End` hotkeys (rebindable).
|
||||
- **Escaping the composer with ↑** returns to the last-focused note for
|
||||
that level, not the bottommost.
|
||||
- **Going up a level (←/Backspace)** pins the note you came from to the
|
||||
top of the parent list, so the next up/down moves relative to it.
|
||||
- The in-list **"Nest under…"** picker skips over the notes being moved
|
||||
when you arrow through candidates.
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { TFile, type App } from "obsidian";
|
||||
import { ROOT_ID, type StashpadId, type TreeNode } from "./types";
|
||||
import type { TreeIndex } from "./tree-index";
|
||||
import { perf } from "./perf";
|
||||
|
||||
const PARENT_LINK_FIELD = "parentLink";
|
||||
const CHILDREN_FIELD = "children";
|
||||
|
|
@ -213,12 +214,12 @@ export class FrontmatterSyncQueue {
|
|||
const parentLink = this.computeParentLink(node);
|
||||
const childrenLinks = this.computeChildrenLinks(node);
|
||||
try {
|
||||
await this.app.fileManager.processFrontMatter(node.file, (fm) => {
|
||||
await perf.timeAsync("write.fmSync", () => this.app.fileManager.processFrontMatter(node.file!, (fm) => {
|
||||
if (parentLink) fm[PARENT_LINK_FIELD] = parentLink;
|
||||
else delete fm[PARENT_LINK_FIELD];
|
||||
if (childrenLinks.length > 0) fm[CHILDREN_FIELD] = childrenLinks;
|
||||
else delete fm[CHILDREN_FIELD];
|
||||
});
|
||||
}));
|
||||
} catch (e) {
|
||||
console.warn("[Stashpad] frontmatter sync failed", node.file?.path, e);
|
||||
// Surface to subscribers (the view turns this into a persistent
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Notice, TFile, TFolder } from "obsidian";
|
||||
import type StashpadPlugin from "./main";
|
||||
import { ROOT_ID, RESERVED_FRONTMATTER } from "./types";
|
||||
import { ROOT_ID, RESERVED_FRONTMATTER, toAttachmentLink } from "./types";
|
||||
import { newId } from "./id-service";
|
||||
import { bodyToSlug, buildFilename } from "./slug-service";
|
||||
import { splitFrontmatter, serializeNote, STASH_EXT } from "./stash-package";
|
||||
|
|
@ -89,11 +89,23 @@ export class ImportService {
|
|||
/** Path-level eligibility (no content read): the file sits directly in a
|
||||
* discovered Stashpad folder ROOT (not a reserved subfolder), and isn't
|
||||
* a .stash archive (those have their own importer) or our own .edtz. */
|
||||
/** Suppress auto-import for `path` for a few seconds — used around our
|
||||
* own moves so the resulting vault event doesn't re-import the file. */
|
||||
private suppress(path: string): void {
|
||||
/** Suppress auto-import for `path` for `ttl` ms — used around our own
|
||||
* moves (undo restores) and for Stashpad-created notes so the resulting
|
||||
* vault event doesn't re-import the file. 0.79.20: public + tunable TTL;
|
||||
* createNoteUnder uses a long window because on a slow network drive the
|
||||
* create event (and the frontmatter flush its id-check needs) can lag
|
||||
* well past a few seconds. */
|
||||
suppress(path: string, ttl = 4000): void {
|
||||
this.suppressed.add(path);
|
||||
window.setTimeout(() => this.suppressed.delete(path), 4000);
|
||||
window.setTimeout(() => this.suppressed.delete(path), ttl);
|
||||
}
|
||||
|
||||
/** Create a note file, suppressing auto-import for it first — the notes
|
||||
* the importer itself creates must never be re-imported (same slow-drive
|
||||
* frontmatter-flush race as composer-created notes). */
|
||||
private async createNote(path: string, content: string): Promise<void> {
|
||||
this.suppress(path, 60000);
|
||||
await this.app.vault.create(path, content);
|
||||
}
|
||||
|
||||
private isEligiblePath(file: TFile): boolean {
|
||||
|
|
@ -105,15 +117,20 @@ export class ImportService {
|
|||
return this.plugin.discoverStashpadFolders().includes(dir);
|
||||
}
|
||||
|
||||
/** Re-validate at drain time (after the debounce, when the metadata
|
||||
* cache has had a chance to parse). A markdown file that already has a
|
||||
* Stashpad `id` is an existing note (or one we just created) — skip it.
|
||||
* Reads frontmatter from the cache; that's settled by now. */
|
||||
private isStillImportable(file: TFile): boolean {
|
||||
/** Re-validate at drain time. A markdown file that already has a Stashpad
|
||||
* `id` is an existing note (or one we just created) — skip it. 0.79.18:
|
||||
* read the frontmatter FROM DISK rather than the metadata cache. The
|
||||
* cache lags badly on mobile, so an existing note (re)appearing via a
|
||||
* rename event — e.g. Stashpad's own slug-rename after an edit — looked
|
||||
* id-less and got "imported" again, archiving + cloning it in an endless
|
||||
* loop. Reading the file is authoritative and breaks that. */
|
||||
private async isStillImportable(file: TFile): Promise<boolean> {
|
||||
if (!this.isEligiblePath(file)) return false;
|
||||
if (file.extension === "md") {
|
||||
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as any;
|
||||
if (fm && typeof fm.id === "string" && fm.id) return false;
|
||||
try {
|
||||
const { fm } = splitFrontmatter(await this.app.vault.read(file));
|
||||
if (fm && typeof fm.id === "string" && fm.id) return false;
|
||||
} catch { /* unreadable — fall through and let the import try */ }
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
|
@ -125,10 +142,13 @@ export class ImportService {
|
|||
try {
|
||||
const paths = [...this.pending.keys()];
|
||||
this.pending.clear();
|
||||
const files = paths
|
||||
const candidates = paths
|
||||
.map((p) => this.app.vault.getAbstractFileByPath(p))
|
||||
.filter((f): f is TFile => f instanceof TFile)
|
||||
.filter((f) => this.isStillImportable(f));
|
||||
.filter((f): f is TFile => f instanceof TFile);
|
||||
const files: TFile[] = [];
|
||||
for (const f of candidates) {
|
||||
if (await this.isStillImportable(f)) files.push(f);
|
||||
}
|
||||
|
||||
// Eligible dropped folders, with their (non-empty) file lists.
|
||||
const folderPaths = [...this.pendingFolders.keys()];
|
||||
|
|
@ -253,6 +273,12 @@ export class ImportService {
|
|||
await this.ensureFolder(archiveDir);
|
||||
const archivePath = await this.uniquePath(archiveDir, file.name);
|
||||
await this.app.fileManager.renameFile(file, archivePath);
|
||||
// 0.79.21: conflict guard — if the archive move didn't actually land,
|
||||
// ABORT before creating the clone. Otherwise we'd produce a clone while
|
||||
// the original is lost (the pre-0.79.10 .archive dot-folder failure).
|
||||
if (!(this.app.vault.getAbstractFileByPath(archivePath) instanceof TFile)) {
|
||||
throw new Error(`archive move failed for ${file.path} — import aborted to avoid data loss`);
|
||||
}
|
||||
|
||||
// Build the clone's frontmatter: keep the user's non-reserved keys,
|
||||
// then stamp Stashpad's structural fields.
|
||||
|
|
@ -262,12 +288,16 @@ export class ImportService {
|
|||
}
|
||||
cloneFm.id = newId();
|
||||
cloneFm.parent = ROOT_ID;
|
||||
cloneFm.created = new Date().toISOString();
|
||||
// 0.79.21: preserve the original timestamps — don't stamp "now" over a
|
||||
// note that already has a created/modified (e.g. re-imported export).
|
||||
const t = this.preservedTimes(fm, file);
|
||||
cloneFm.created = t.created;
|
||||
if (t.modified) cloneFm.modified = t.modified;
|
||||
cloneFm.attachments = Array.isArray(fm.attachments) ? fm.attachments : [];
|
||||
|
||||
const slug = bodyToSlug(body) || file.basename;
|
||||
const notePath = await this.uniquePath(folder, buildFilename(slug, cloneFm.id));
|
||||
await this.app.vault.create(notePath, serializeNote(cloneFm, body));
|
||||
await this.createNote(notePath, serializeNote(cloneFm, body));
|
||||
return { kind: "md", folder, archivePath, notePath, originalName: file.name };
|
||||
}
|
||||
|
||||
|
|
@ -287,16 +317,30 @@ export class ImportService {
|
|||
id,
|
||||
parent: ROOT_ID,
|
||||
created: new Date().toISOString(),
|
||||
attachments: [attachmentPath],
|
||||
// 0.79.18: attachments stored as internal links (not plain text).
|
||||
attachments: [toAttachmentLink(attachmentPath)],
|
||||
};
|
||||
// Body: a wikilink to the attachment (link, not embed).
|
||||
const body = `${title}\n\n[[${attachmentPath}]]\n`;
|
||||
// 0.79.18: embed the attachment (! prefix) so it previews inline.
|
||||
const body = `${title}\n\n![[${attachmentPath}]]\n`;
|
||||
const slug = bodyToSlug(title) || title;
|
||||
const notePath = await this.uniquePath(folder, buildFilename(slug, id));
|
||||
await this.app.vault.create(notePath, serializeNote(fm, body));
|
||||
await this.createNote(notePath, serializeNote(fm, body));
|
||||
return { kind: "file", folder, attachmentPath, notePath, originalName: file.name };
|
||||
}
|
||||
|
||||
/** Choose the created/modified to stamp on an imported note: prefer the
|
||||
* source frontmatter's own values; else fall back to the file's
|
||||
* filesystem ctime/mtime (usually survives a rename, far better than
|
||||
* "now"); else, only as a last resort, now. */
|
||||
private preservedTimes(fm: Record<string, any>, file: TFile): { created: string; modified: string | null } {
|
||||
const valid = (v: unknown): v is string => typeof v === "string" && v.trim().length > 0 && !Number.isNaN(Date.parse(v));
|
||||
const created = valid(fm.created) ? fm.created
|
||||
: (file.stat?.ctime ? new Date(file.stat.ctime).toISOString() : new Date().toISOString());
|
||||
const modified = valid(fm.modified) ? fm.modified
|
||||
: (file.stat?.mtime ? new Date(file.stat.mtime).toISOString() : null);
|
||||
return { created, modified };
|
||||
}
|
||||
|
||||
/** All files anywhere under `folderPath` (recursive). */
|
||||
private filesUnder(folderPath: string): TFile[] {
|
||||
const prefix = folderPath.replace(/\/+$/, "") + "/";
|
||||
|
|
@ -340,7 +384,7 @@ export class ImportService {
|
|||
};
|
||||
const slug = bodyToSlug(title) || title;
|
||||
const notePath = await this.uniquePath(root, buildFilename(slug, id));
|
||||
await this.app.vault.create(notePath, serializeNote(fm, `${title}\n`));
|
||||
await this.createNote(notePath, serializeNote(fm, `${title}\n`));
|
||||
notePaths.push(notePath);
|
||||
return id;
|
||||
}
|
||||
|
|
@ -363,22 +407,25 @@ export class ImportService {
|
|||
}
|
||||
cloneFm.id = newId();
|
||||
cloneFm.parent = parentId;
|
||||
cloneFm.created = new Date().toISOString();
|
||||
const t = this.preservedTimes(fm, child);
|
||||
cloneFm.created = t.created;
|
||||
if (t.modified) cloneFm.modified = t.modified;
|
||||
cloneFm.attachments = Array.isArray(fm.attachments) ? fm.attachments : [];
|
||||
const slug = bodyToSlug(body) || child.basename;
|
||||
const notePath = await this.uniquePath(root, buildFilename(slug, cloneFm.id));
|
||||
await this.app.vault.create(notePath, serializeNote(cloneFm, body));
|
||||
await this.createNote(notePath, serializeNote(cloneFm, body));
|
||||
notePaths.push(notePath);
|
||||
} else {
|
||||
// Link the archived file (no _attachments copy).
|
||||
const id = newId();
|
||||
const fm: Record<string, any> = {
|
||||
id, parent: parentId, created: new Date().toISOString(), attachments: [child.path],
|
||||
id, parent: parentId, created: new Date().toISOString(),
|
||||
attachments: [toAttachmentLink(child.path)],
|
||||
};
|
||||
const body = `${child.basename}\n\n[[${child.path}]]\n`;
|
||||
const body = `${child.basename}\n\n![[${child.path}]]\n`;
|
||||
const slug = bodyToSlug(child.basename) || child.basename;
|
||||
const notePath = await this.uniquePath(root, buildFilename(slug, id));
|
||||
await this.app.vault.create(notePath, serializeNote(fm, body));
|
||||
await this.createNote(notePath, serializeNote(fm, body));
|
||||
notePaths.push(notePath);
|
||||
}
|
||||
}
|
||||
|
|
@ -482,33 +529,55 @@ export class ImportService {
|
|||
new Notice(`Undid import of ${records.length} file(s).`);
|
||||
}
|
||||
|
||||
/** 0.79.4: open the OS file picker and copy the chosen files into
|
||||
* `folder`'s root; the auto-import watcher then turns them into notes.
|
||||
* Uses an <input type=file> so it works on desktop AND mobile (native
|
||||
* file/photo picker) without Electron-version juggling. */
|
||||
/** 0.79.4 / 0.80.1: open the OS file picker, copy the chosen files into
|
||||
* `folder`, then import them DIRECTLY (not via the watcher) so this works
|
||||
* regardless of the auto-import toggle. Uses an <input type=file> so it
|
||||
* works on desktop AND mobile (native file/photo picker). */
|
||||
pickFilesInto(folder: string): void {
|
||||
const input = document.createElement("input");
|
||||
input.type = "file";
|
||||
input.multiple = true;
|
||||
input.style.display = "none";
|
||||
input.onchange = async () => {
|
||||
const files = Array.from(input.files ?? []);
|
||||
const picked = Array.from(input.files ?? []);
|
||||
input.remove();
|
||||
if (files.length === 0) return;
|
||||
let written = 0;
|
||||
for (const file of files) {
|
||||
if (picked.length === 0) return;
|
||||
// 1) write each picked file into the folder root (suppressed so the
|
||||
// watcher, if armed, doesn't ALSO process it — we import below).
|
||||
const written: TFile[] = [];
|
||||
for (const file of picked) {
|
||||
try {
|
||||
const buf = await file.arrayBuffer();
|
||||
const dest = await this.uniquePath(folder, file.name);
|
||||
this.suppress(dest, 60000);
|
||||
await this.app.vault.createBinary(dest, buf);
|
||||
written++;
|
||||
const tf = this.app.vault.getAbstractFileByPath(dest);
|
||||
if (tf instanceof TFile) written.push(tf);
|
||||
} catch (e) {
|
||||
console.warn("[Stashpad] file-picker import failed", file.name, e);
|
||||
console.warn("[Stashpad] file-picker write failed", file.name, e);
|
||||
}
|
||||
}
|
||||
if (written > 0) {
|
||||
new Notice(`Added ${written} file(s) to "${folder.split("/").pop()}" — importing…`);
|
||||
if (written.length === 0) return;
|
||||
// 2) import the written files directly.
|
||||
await this.plugin.importLog.load();
|
||||
const records: ImportRecord[] = [];
|
||||
for (const f of written) {
|
||||
try {
|
||||
const size = f.stat?.size ?? null;
|
||||
const rec = f.extension === "md" ? await this.importMarkdown(f) : await this.importOtherFile(f);
|
||||
if (rec) {
|
||||
records.push(rec);
|
||||
this.plugin.importLog.append({
|
||||
ts: new Date().toISOString(), folder: rec.folder, kind: rec.kind,
|
||||
originalName: f.name, size, sourcePath: f.path,
|
||||
notePaths: rec.kind === "folder" ? rec.notePaths : [rec.notePath],
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[Stashpad] file-picker import failed", f.path, e);
|
||||
}
|
||||
}
|
||||
if (records.length > 0) this.announce(records, []);
|
||||
};
|
||||
document.body.appendChild(input);
|
||||
input.click();
|
||||
|
|
@ -524,9 +593,12 @@ export class ImportService {
|
|||
* mode to surface. */
|
||||
async importLooseFilesIn(folder: string): Promise<number> {
|
||||
const root = folder.replace(/\/+$/, "");
|
||||
const files = this.app.vault.getFiles()
|
||||
.filter((f) => (f.parent?.path?.replace(/\/+$/, "") ?? "") === root)
|
||||
.filter((f) => this.isStillImportable(f));
|
||||
const candidates = this.app.vault.getFiles()
|
||||
.filter((f) => (f.parent?.path?.replace(/\/+$/, "") ?? "") === root);
|
||||
const files: TFile[] = [];
|
||||
for (const f of candidates) {
|
||||
if (await this.isStillImportable(f)) files.push(f);
|
||||
}
|
||||
if (files.length === 0) return 0;
|
||||
await this.plugin.importLog.load();
|
||||
let n = 0;
|
||||
|
|
|
|||
111
src/main.ts
111
src/main.ts
|
|
@ -1,5 +1,5 @@
|
|||
import { Notice, Platform, Plugin, SuggestModal, TFile, TFolder, WorkspaceLeaf, setIcon } from "obsidian";
|
||||
import { STASHPAD_DETAIL_VIEW_TYPE, STASHPAD_PANELS_VIEW_TYPE, STASHPAD_VIEW_TYPE, parseAuthorRef, type PinnedNoteRef, type StashpadId } from "./types";
|
||||
import { STASHPAD_DETAIL_VIEW_TYPE, STASHPAD_PANELS_VIEW_TYPE, STASHPAD_VIEW_TYPE, parseAuthorRef, toAttachmentLink, isInReservedSubfolder, type PinnedNoteRef, type StashpadId } from "./types";
|
||||
import { StashpadDetailView, openStashpadDetailView } from "./detail-view";
|
||||
import { StashpadView, properCaseFolderPath } from "./view";
|
||||
import { StashpadPanelsView, openStashpadPanelsView, PANEL_REGISTRY, type PanelId } from "./panels-view";
|
||||
|
|
@ -18,6 +18,7 @@ import { NotificationService, buildFileActions } from "./notifications";
|
|||
import { AuthorRegistry } from "./author-registry";
|
||||
import { ImportService } from "./import-service";
|
||||
import { ImportLog } from "./import-log";
|
||||
import { perf } from "./perf";
|
||||
|
||||
export default class StashpadPlugin extends Plugin {
|
||||
settings: StashpadSettings = { ...DEFAULT_SETTINGS };
|
||||
|
|
@ -26,6 +27,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
* Used by sidebar panel actions (Search, Home) so they target the
|
||||
* user's actual current tab rather than getLeavesOfType()[0]. */
|
||||
lastActiveStashpadLeaf: WorkspaceLeaf | null = null;
|
||||
/** 0.79.19: true while rebootstrap is running. Suppresses the
|
||||
* contribution stamp so rebootstrap's own frontmatter writes — and the
|
||||
* wikilink rewrites Obsidian does when slug-renames move files — never
|
||||
* bump `modified` (or add the local user as a contributor). */
|
||||
rebootstrapInProgress = false;
|
||||
/** 0.73.10: keep a handle on the settings tab so command-palette
|
||||
* entries can pre-select a specific tab when opening Settings. */
|
||||
settingTab: StashpadSettingTab | null = null;
|
||||
|
|
@ -470,6 +476,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
// loadSettings so the data.json move is in place when we read.
|
||||
await this.migrateLegacyPaths();
|
||||
await this.loadSettings();
|
||||
perf.enabled = !!this.settings.enablePerfProfiling;
|
||||
this.settingTab = new StashpadSettingTab(this.app, this);
|
||||
this.addSettingTab(this.settingTab);
|
||||
|
||||
|
|
@ -825,6 +832,28 @@ export default class StashpadPlugin extends Plugin {
|
|||
this.addCommand({ id: "stashpad-toggle-complete", name: "Toggle complete (strikethrough)", callback: () => call("cmdToggleComplete") });
|
||||
this.addCommand({ id: "stashpad-toggle-task", name: "Toggle task (todo)", callback: () => call("cmdToggleTask") });
|
||||
this.addCommand({ id: "stashpad-set-due", name: "Set due date…", callback: () => call("cmdSetDue") });
|
||||
// 0.81.1: performance profiling — dump / reset the timing report.
|
||||
this.addCommand({
|
||||
id: "stashpad-dump-perf",
|
||||
name: "Dump performance profile (copy to clipboard)",
|
||||
callback: async () => {
|
||||
if (!this.settings.enablePerfProfiling) {
|
||||
new Notice("Enable “Performance profiling” in Stashpad settings first, then use the app and run this again.");
|
||||
return;
|
||||
}
|
||||
const report = perf.report();
|
||||
console.log(report);
|
||||
try { await navigator.clipboard.writeText(report); } catch {}
|
||||
new Notice("Performance profile copied to clipboard (also in the console).");
|
||||
},
|
||||
});
|
||||
this.addCommand({
|
||||
id: "stashpad-reset-perf",
|
||||
name: "Reset performance profile",
|
||||
callback: () => { perf.reset(); new Notice("Performance profile reset."); },
|
||||
});
|
||||
this.addCommand({ id: "stashpad-jump-to-top", name: "Jump to top of list", callback: () => call("jumpToTop") });
|
||||
this.addCommand({ id: "stashpad-jump-to-bottom", name: "Jump to bottom of list", callback: () => call("jumpToBottom") });
|
||||
this.addCommand({ id: "stashpad-assign", name: "Assign task to…", callback: () => call("cmdAssign") });
|
||||
// 0.79.3: view what's been auto-imported.
|
||||
this.addCommand({
|
||||
|
|
@ -1001,10 +1030,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
callback: async () => {
|
||||
new Notice("Stashpad: rebootstrapping…");
|
||||
try {
|
||||
const { touched, fmChecked, fmWritten, slugsRenamed, authors, imported } = await this.rebootstrapAllFolders();
|
||||
const { touched, fmChecked, fmWritten, slugsRenamed, authors, imported, attachmentsLinked } = await this.rebootstrapAllFolders();
|
||||
const parts: string[] = [];
|
||||
parts.push(`rebootstrapped ${touched.length} folder${touched.length === 1 ? "" : "s"}`);
|
||||
if (imported > 0) parts.push(`imported ${imported} loose file${imported === 1 ? "" : "s"}`);
|
||||
if (attachmentsLinked > 0) parts.push(`linked attachments on ${attachmentsLinked} note${attachmentsLinked === 1 ? "" : "s"}`);
|
||||
if (fmWritten > 0) parts.push(`updated ${fmWritten} note${fmWritten === 1 ? "" : "s"}' metadata`);
|
||||
if (slugsRenamed > 0) parts.push(`renamed ${slugsRenamed} note${slugsRenamed === 1 ? "" : "s"}`);
|
||||
if (authors > 0) parts.push(`${authors} author${authors === 1 ? "" : "s"} in registry`);
|
||||
|
|
@ -1697,7 +1727,19 @@ export default class StashpadPlugin extends Plugin {
|
|||
* ensure it has the import/export subfolders, and run the redundant-frontmatter
|
||||
* backfill (parentLink + children) so older notes pick up the recovery fields.
|
||||
* Used by the "Rebootstrap" button in settings to retrofit older folders. */
|
||||
async rebootstrapAllFolders(): Promise<{ touched: string[]; fmChecked: number; fmWritten: number; slugsRenamed: number; authors: number; imported: number }> {
|
||||
async rebootstrapAllFolders(): Promise<{ touched: string[]; fmChecked: number; fmWritten: number; slugsRenamed: number; authors: number; imported: number; attachmentsLinked: number }> {
|
||||
// 0.79.19: suppress contribution stamping for the duration (+ a short
|
||||
// tail to catch async link-rewrite modify events) so rebootstrap never
|
||||
// bumps `modified`/`created` or adds contributors.
|
||||
this.rebootstrapInProgress = true;
|
||||
try {
|
||||
return await this.rebootstrapAllFoldersInner();
|
||||
} finally {
|
||||
window.setTimeout(() => { this.rebootstrapInProgress = false; }, 2500);
|
||||
}
|
||||
}
|
||||
|
||||
private async rebootstrapAllFoldersInner(): Promise<{ touched: string[]; fmChecked: number; fmWritten: number; slugsRenamed: number; authors: number; imported: number; attachmentsLinked: number }> {
|
||||
const ROOT_ID = "__root__";
|
||||
const seen = new Set<string>();
|
||||
for (const f of this.app.vault.getMarkdownFiles()) {
|
||||
|
|
@ -1762,7 +1804,11 @@ export default class StashpadPlugin extends Plugin {
|
|||
let authors = 0;
|
||||
try { authors = (await this.rebuildAuthorRegistry()).total; }
|
||||
catch (e) { console.warn("Stashpad: rebootstrap author-registry rebuild failed", e); }
|
||||
return { touched, fmChecked, fmWritten, slugsRenamed, authors, imported };
|
||||
// 0.79.18: convert any plain-text attachment frontmatter to links.
|
||||
let attachmentsLinked = 0;
|
||||
try { attachmentsLinked = await this.convertAttachmentsToLinks(); }
|
||||
catch (e) { console.warn("Stashpad: attachment-link conversion failed", e); }
|
||||
return { touched, fmChecked, fmWritten, slugsRenamed, authors, imported, attachmentsLinked };
|
||||
}
|
||||
|
||||
/** Walk every Stashpad note in `folder`. For each one whose filename
|
||||
|
|
@ -2440,6 +2486,38 @@ export default class StashpadPlugin extends Plugin {
|
|||
return { created, folders: folders.length };
|
||||
}
|
||||
|
||||
/** 0.79.18: convert plain-text `attachments` frontmatter entries to
|
||||
* internal links (`[[path]]`) across all notes. Idempotent — only
|
||||
* rewrites notes that have at least one non-link entry, and
|
||||
* `toAttachmentLink` never re-brackets an existing link, so re-running
|
||||
* can't double-wrap or loop. Returns the count of notes changed. */
|
||||
async convertAttachmentsToLinks(): Promise<number> {
|
||||
let converted = 0;
|
||||
const isLink = (s: string) => /^\[\[.*\]\]$/.test(s.trim());
|
||||
for (const folder of this.discoverStashpadFolders()) {
|
||||
const dir = folder.replace(/\/+$/, "");
|
||||
for (const f of this.app.vault.getMarkdownFiles()) {
|
||||
const fdir = f.parent?.path?.replace(/\/+$/, "") ?? "";
|
||||
if (fdir !== dir && !fdir.startsWith(dir + "/")) continue;
|
||||
if (isInReservedSubfolder(f.path)) continue; // skip _archive/_attachments/…
|
||||
const fm = this.app.metadataCache.getFileCache(f)?.frontmatter as any;
|
||||
if (!fm || typeof fm.id !== "string" || !fm.id) continue;
|
||||
if (!Array.isArray(fm.attachments)) continue;
|
||||
const needs = fm.attachments.some((a: any) => typeof a === "string" && a.trim() && !isLink(a));
|
||||
if (!needs) continue;
|
||||
try {
|
||||
await this.app.fileManager.processFrontMatter(f, (m: any) => {
|
||||
if (Array.isArray(m.attachments)) {
|
||||
m.attachments = m.attachments.map((a: any) => (typeof a === "string" && a.trim()) ? toAttachmentLink(a) : a);
|
||||
}
|
||||
});
|
||||
converted++;
|
||||
} catch (e) { console.warn("[Stashpad] attachment-link conversion failed", f.path, e); }
|
||||
}
|
||||
}
|
||||
return converted;
|
||||
}
|
||||
|
||||
/** 0.79.12: add each discovered Stashpad folder's `_archive` to
|
||||
* Obsidian's "Excluded files" list (`userIgnoreFilters`) so native
|
||||
* search, quick switcher, graph, and link suggestions skip the
|
||||
|
|
@ -2720,6 +2798,7 @@ export default class StashpadPlugin extends Plugin {
|
|||
async saveSettings(): Promise<void> {
|
||||
await this.queueWrite();
|
||||
setSettings(this.settings);
|
||||
perf.enabled = !!this.settings.enablePerfProfiling;
|
||||
// 0.77.1: keep the registry's record of the local user current. The
|
||||
// registry is a recovery cache — recording here means a name/role/
|
||||
// department change is remembered (with rename history) even if the
|
||||
|
|
@ -3030,10 +3109,10 @@ function normalizeDrafts(raw: any): Record<string, string> {
|
|||
return out;
|
||||
}
|
||||
|
||||
/** 0.79.4: destination chooser for the Import command. The pinned top
|
||||
* entry opens the OS file picker into the default folder; the rest target
|
||||
* a specific Stashpad folder. */
|
||||
interface ImportTarget { label: string; folder: string; pinned?: boolean }
|
||||
/** 0.79.4 / 0.80.1: destination chooser for the Import command. Lists the
|
||||
* Stashpad folders with the current/active one first; picking one opens
|
||||
* the OS file picker into that folder and imports the chosen files. */
|
||||
interface ImportTarget { label: string; folder: string; current?: boolean }
|
||||
class ImportTargetModal extends SuggestModal<ImportTarget> {
|
||||
constructor(
|
||||
app: import("obsidian").App,
|
||||
|
|
@ -3042,20 +3121,22 @@ class ImportTargetModal extends SuggestModal<ImportTarget> {
|
|||
private onPick: (folder: string) => void,
|
||||
) {
|
||||
super(app);
|
||||
this.setPlaceholder("Open the file picker, or choose a destination folder…");
|
||||
this.setPlaceholder("Choose a Stashpad folder to import into…");
|
||||
}
|
||||
getSuggestions(query: string): ImportTarget[] {
|
||||
const q = query.toLowerCase();
|
||||
const defName = this.def.split("/").pop() || this.def;
|
||||
const pinned: ImportTarget = { label: `📂 Open file picker → ${defName}`, folder: this.def, pinned: true };
|
||||
const rest = this.folders
|
||||
// Current folder first, then the rest (deduped), filtered by query.
|
||||
const ordered = [this.def, ...this.folders.filter((f) => f !== this.def)];
|
||||
return ordered
|
||||
.filter((f) => f.toLowerCase().includes(q))
|
||||
.map((f) => ({ label: `Import into ${f}`, folder: f }));
|
||||
return [pinned, ...rest];
|
||||
.map((f) => ({ label: f, folder: f, current: f === this.def }));
|
||||
}
|
||||
renderSuggestion(item: ImportTarget, el: HTMLElement): void {
|
||||
el.createDiv({ text: item.label });
|
||||
if (item.pinned) el.addClass("is-pinned-import-target");
|
||||
if (item.current) {
|
||||
el.createDiv({ cls: "stashpad-suggest-note", text: "current" });
|
||||
el.addClass("is-pinned-import-target");
|
||||
}
|
||||
}
|
||||
onChooseSuggestion(item: ImportTarget): void { this.onPick(item.folder); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Notice, TFile, type App } from "obsidian";
|
||||
import { Notice, Platform, TFile, type App } from "obsidian";
|
||||
import type { StashpadId } from "./types";
|
||||
|
||||
/** Visual severity. Drives the toast's color + the history panel's
|
||||
|
|
@ -353,8 +353,13 @@ export function buildFileActions(
|
|||
},
|
||||
}];
|
||||
if (!isMobile) {
|
||||
// Platform-correct OS file-manager name (Windows: "File Explorer",
|
||||
// capitalised; macOS: "Finder"; otherwise generic).
|
||||
const osManager = Platform.isMacOS ? "Finder"
|
||||
: Platform.isWin ? "File Explorer"
|
||||
: "file manager";
|
||||
actions.push({
|
||||
label: "Show in Finder",
|
||||
label: `Show in ${osManager}`,
|
||||
keepOpen: true,
|
||||
onClick: () => {
|
||||
try {
|
||||
|
|
|
|||
60
src/perf.ts
Normal file
60
src/perf.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
/** 0.81.1: tiny opt-in profiler. Accumulates per-label timing buckets so we
|
||||
* can see where Stashpad spends its time on a slow (e.g. network-drive)
|
||||
* vault — split across the three suspects: rendering markdown, reading
|
||||
* bodies, and writing files. Zero overhead when disabled (the time/record
|
||||
* calls early-return). Toggle via the `enablePerfProfiling` setting; dump
|
||||
* a report with the "Dump performance profile" command. */
|
||||
interface Bucket { count: number; total: number; max: number; }
|
||||
|
||||
class Profiler {
|
||||
enabled = false;
|
||||
private buckets = new Map<string, Bucket>();
|
||||
|
||||
record(label: string, ms: number): void {
|
||||
if (!this.enabled) return;
|
||||
let b = this.buckets.get(label);
|
||||
if (!b) { b = { count: 0, total: 0, max: 0 }; this.buckets.set(label, b); }
|
||||
b.count += 1;
|
||||
b.total += ms;
|
||||
if (ms > b.max) b.max = ms;
|
||||
}
|
||||
|
||||
/** Time a synchronous block. Returns its result. */
|
||||
time<T>(label: string, fn: () => T): T {
|
||||
if (!this.enabled) return fn();
|
||||
const t0 = performance.now();
|
||||
try { return fn(); } finally { this.record(label, performance.now() - t0); }
|
||||
}
|
||||
|
||||
/** Time an async block. */
|
||||
async timeAsync<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
||||
if (!this.enabled) return fn();
|
||||
const t0 = performance.now();
|
||||
try { return await fn(); } finally { this.record(label, performance.now() - t0); }
|
||||
}
|
||||
|
||||
reset(): void { this.buckets.clear(); }
|
||||
|
||||
hasData(): boolean { return this.buckets.size > 0; }
|
||||
|
||||
/** A sorted Markdown table (by total time desc) — renders as a table in
|
||||
* a note and stays readable as raw text. */
|
||||
report(): string {
|
||||
const rows = [...this.buckets.entries()]
|
||||
.map(([label, b]) => ({ label, ...b, avg: b.total / b.count }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
if (rows.length === 0) return "Stashpad perf: no samples (enable profiling, then use the app).";
|
||||
const lines = [
|
||||
"**Stashpad performance profile**",
|
||||
"",
|
||||
"| label | count | total (ms) | avg (ms) | max (ms) |",
|
||||
"| --- | ---: | ---: | ---: | ---: |",
|
||||
];
|
||||
for (const r of rows) {
|
||||
lines.push(`| \`${r.label}\` | ${r.count} | ${r.total.toFixed(0)} | ${r.avg.toFixed(1)} | ${r.max.toFixed(0)} |`);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
}
|
||||
|
||||
export const perf = new Profiler();
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import { App, Notice, PluginSettingTab, Setting, TFile } from "obsidian";
|
||||
import { App, Notice, Platform, PluginSettingTab, Setting, TFile } from "obsidian";
|
||||
|
||||
/** Platform-correct OS file-manager name for button/notice labels. */
|
||||
function osFileManagerName(): string {
|
||||
return Platform.isMacOS ? "Finder" : Platform.isWin ? "File Explorer" : "file manager";
|
||||
}
|
||||
import { buildJdIndexPreview, buildJdIndexNotes, scanForJdNotes, JdBuildConfirmModal, buildJdPreviewNotice } from "./index-builder";
|
||||
import { FolderSuggest } from "./folder-suggest";
|
||||
import type StashpadPlugin from "./main";
|
||||
|
|
@ -53,7 +58,8 @@ export type CommandId =
|
|||
| "cloneStashpadTab" | "selectAll" | "copyCodeBlock"
|
||||
| "swapWithParent"
|
||||
| "togglePin"
|
||||
| "toggleTask" | "setDue";
|
||||
| "toggleTask" | "setDue"
|
||||
| "jumpToTop" | "jumpToBottom";
|
||||
|
||||
/** Per-command bindings: up to two chord strings ("S" or "Mod+Enter").
|
||||
* When BOTH are set, `preferRight` decides which actually fires. */
|
||||
|
|
@ -114,6 +120,8 @@ export const COMMAND_META: CommandMeta[] = [
|
|||
{ id: "togglePin", label: "Pin / unpin selected note", desc: "Default: P — toggle the sidebar pin state of the cursor row (or focused note).", defaultPrimary: "P" },
|
||||
{ id: "toggleTask", label: "Toggle task (todo)", desc: "Default: H — mark the selection (or cursor row) as a task / todo, or clear it. Tasks appear in the Tasks panel.", defaultPrimary: "H" },
|
||||
{ id: "setDue", label: "Set due date…", desc: "Default: D — open a date+time picker to set (or clear) the due date on the selection. Setting a due date also marks the note as a task.", defaultPrimary: "D" },
|
||||
{ id: "jumpToTop", label: "Jump to top of list", desc: "Default: Home — move the cursor to the first note in the current list.", defaultPrimary: "Home" },
|
||||
{ id: "jumpToBottom", label: "Jump to bottom of list", desc: "Default: End — move the cursor to the last note in the current list.", defaultPrimary: "End" },
|
||||
];
|
||||
|
||||
export function buildDefaultBindings(): CommandBindingMap {
|
||||
|
|
@ -136,6 +144,10 @@ export interface StashpadSettings {
|
|||
* honor Obsidian's "Excluded files" (userIgnoreFilters), so exclusions
|
||||
* are managed in one place. `.edtz` is always excluded regardless. */
|
||||
inheritObsidianExclusions: boolean;
|
||||
/** 0.81.1: opt-in performance profiling — accumulates render/read/write
|
||||
* timing so the "Dump performance profile" command reports where the
|
||||
* time goes on a slow vault. Off by default. */
|
||||
enablePerfProfiling: boolean;
|
||||
useTemplatesFormat: boolean;
|
||||
prefixTimestampsOnCopy: boolean;
|
||||
splitOnLines: boolean;
|
||||
|
|
@ -344,8 +356,9 @@ export const DEFAULT_SETTINGS: StashpadSettings = {
|
|||
folder: "Stashpad",
|
||||
importDropFolder: "",
|
||||
exportFolder: "_exports",
|
||||
autoImport: true,
|
||||
autoImport: false,
|
||||
inheritObsidianExclusions: true,
|
||||
enablePerfProfiling: false,
|
||||
useTemplatesFormat: false,
|
||||
prefixTimestampsOnCopy: true,
|
||||
splitOnLines: false,
|
||||
|
|
@ -594,6 +607,13 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
/** Diagnostics tab: log + notification controls. Lifted verbatim
|
||||
* from the pre-0.73.1 Log section. Inventory items A1–A4. */
|
||||
private renderDiagnosticsTab(parent: HTMLElement): void {
|
||||
new Setting(parent)
|
||||
.setName("Performance profiling")
|
||||
.setDesc("Record timing for list rendering, body reads, and file writes. Turn on, use Stashpad normally (especially the slow operations), then run “Dump performance profile” from the command palette and share the result. Off = zero overhead.")
|
||||
.addToggle((t) => t.setValue(this.plugin.settings.enablePerfProfiling).onChange(async (v) => {
|
||||
this.plugin.settings.enablePerfProfiling = v; await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
new Setting(parent)
|
||||
.setName("Open log file")
|
||||
.setDesc("Append-only history of creates, deletes, parent changes, renames. Stored alongside the plugin's other private files.")
|
||||
|
|
@ -743,10 +763,11 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
b.setButtonText("Rebootstrap now").onClick(async () => {
|
||||
b.setDisabled(true).setButtonText("Working…");
|
||||
try {
|
||||
const { touched, fmChecked, fmWritten, slugsRenamed, authors, imported } = await this.plugin.rebootstrapAllFolders();
|
||||
const { touched, fmChecked, fmWritten, slugsRenamed, authors, imported, attachmentsLinked } = await this.plugin.rebootstrapAllFolders();
|
||||
const parts: string[] = [];
|
||||
parts.push(`rebootstrapped ${touched.length} folder${touched.length === 1 ? "" : "s"}`);
|
||||
if (imported > 0) parts.push(`imported ${imported} loose file${imported === 1 ? "" : "s"}`);
|
||||
if (attachmentsLinked > 0) parts.push(`linked attachments on ${attachmentsLinked} note${attachmentsLinked === 1 ? "" : "s"}`);
|
||||
if (fmWritten > 0) parts.push(`updated frontmatter on ${fmWritten} of ${fmChecked} notes`);
|
||||
else if (fmChecked > 0) parts.push(`frontmatter already in sync (${fmChecked} notes checked)`);
|
||||
if (slugsRenamed > 0) parts.push(`renamed ${slugsRenamed} note${slugsRenamed === 1 ? "" : "s"} to match body`);
|
||||
|
|
@ -1186,7 +1207,7 @@ export class StashpadSettingTab extends PluginSettingTab {
|
|||
});
|
||||
})
|
||||
.addButton((b) => {
|
||||
b.setButtonText("Reveal in Finder");
|
||||
b.setButtonText(`Reveal in ${osFileManagerName()}`);
|
||||
b.setTooltip("Open the designated Stashpad folder in your OS file browser.");
|
||||
b.onClick(async () => {
|
||||
const dest = (this.plugin.settings.jdIndexStashpadFolder ?? "").trim().replace(/^\/+|\/+$/g, "");
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import JSZip from "jszip";
|
||||
import { App, TFile, parseYaml, stringifyYaml } from "obsidian";
|
||||
import { newId } from "./id-service";
|
||||
import { ROOT_ID, type StashpadId } from "./types";
|
||||
import { ROOT_ID, attachmentLinkPath, toAttachmentLink, type StashpadId } from "./types";
|
||||
|
||||
export const STASH_EXT = "stash";
|
||||
export const SCHEMA_VERSION = 1;
|
||||
|
|
@ -198,8 +198,10 @@ export async function importStashZip(
|
|||
import_date: importDate,
|
||||
};
|
||||
if (Array.isArray(newFm.attachments)) {
|
||||
// 0.79.18: attachments may be wikilinks now — normalize to a path,
|
||||
// re-root into the export's attachments folder, re-wrap as a link.
|
||||
newFm.attachments = (newFm.attachments as string[]).map((a) =>
|
||||
`${attachmentsFolder}/${baseFileName(a)}`,
|
||||
toAttachmentLink(`${attachmentsFolder}/${baseFileName(attachmentLinkPath(a))}`),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { TFile, TFolder, type App, type CachedMetadata } from "obsidian";
|
||||
import { ROOT_ID, RESERVED_SUBFOLDER_NAMES, type StashpadId, type TreeNode } from "./types";
|
||||
import { ROOT_ID, RESERVED_SUBFOLDER_NAMES, isInReservedSubfolder, 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
|
||||
|
|
@ -317,6 +317,15 @@ export class TreeIndex {
|
|||
/** Apply a single file create/modify event. Returns true if the tree
|
||||
* changed (caller should schedule onUpdate). */
|
||||
private applyChange(file: TFile): boolean {
|
||||
// 0.80.5: never index files in reserved subfolders (_archive,
|
||||
// _attachments, …). The full rebuild already skips them, but this
|
||||
// incremental hook didn't — so an archived note (which keeps its
|
||||
// Stashpad id) got re-inserted when its metadata cache fired, and then
|
||||
// showed up in every tree-backed picker (find / move / destination).
|
||||
// Clean up any node that slipped in before this guard existed.
|
||||
if (isInReservedSubfolder(file.path)) {
|
||||
return this.byPath.has(file.path) ? this.applyDelete(file.path) : false;
|
||||
}
|
||||
const fm = this.app.metadataCache.getFileCache(file)?.frontmatter;
|
||||
const id = fm?.id as string | undefined;
|
||||
const oldId = this.byPath.get(file.path);
|
||||
|
|
@ -447,6 +456,14 @@ export class TreeIndex {
|
|||
if (!wasIn && !isIn) return false;
|
||||
if (wasIn && !isIn) return this.applyDelete(oldPath);
|
||||
if (!wasIn && isIn) return this.applyChange(file);
|
||||
// 0.80.5: a within-folder move INTO a reserved subfolder (e.g. import
|
||||
// archiving root/Note.md → _archive/Note.md) must drop the node, not
|
||||
// just remap its path — otherwise the archived note lingers in the
|
||||
// tree. (Moving back OUT falls through to applyChange below, since the
|
||||
// archived path was never indexed, and re-adds it.)
|
||||
if (isInReservedSubfolder(file.path)) {
|
||||
return this.byPath.has(oldPath) ? this.applyDelete(oldPath) : false;
|
||||
}
|
||||
// Rename within folder.
|
||||
const id = this.byPath.get(oldPath);
|
||||
if (!id) {
|
||||
|
|
|
|||
24
src/types.ts
24
src/types.ts
|
|
@ -117,6 +117,25 @@ export function isArchivedPath(path: string): boolean {
|
|||
return path.split("/").some((seg) => seg === "_archive" || seg === ".archive");
|
||||
}
|
||||
|
||||
/** 0.79.18: an `attachments` frontmatter entry as a wikilink. Idempotent —
|
||||
* returns an existing `[[...]]` unchanged (never double-brackets), so it's
|
||||
* safe to run repeatedly (e.g. in rebootstrap) without looping. */
|
||||
export function toAttachmentLink(entry: string): string {
|
||||
const s = (entry ?? "").trim();
|
||||
if (!s) return s;
|
||||
if (/^\[\[.*\]\]$/.test(s)) return s;
|
||||
return `[[${s}]]`;
|
||||
}
|
||||
/** The resolvable vault path/linktext inside an attachment entry — strips
|
||||
* `[[ ]]`, a trailing `|alias`, and `#heading`/`^block` refs. Accepts both
|
||||
* the new wikilink form and the legacy plain-path form. */
|
||||
export function attachmentLinkPath(entry: string): string {
|
||||
let s = (entry ?? "").trim();
|
||||
const m = s.match(/^\[\[(.*)\]\]$/);
|
||||
if (m) s = m[1];
|
||||
return s.split("|")[0].split("#")[0].split("^")[0].trim();
|
||||
}
|
||||
|
||||
/** File extensions Stashpad never surfaces in link/search (plugin-internal
|
||||
* formats users don't link to). `.edtz` = Encrypted Templater. */
|
||||
export const IGNORED_FILE_EXTENSIONS: ReadonlySet<string> = new Set(["edtz"]);
|
||||
|
|
@ -202,7 +221,10 @@ export type ScrollPolicy =
|
|||
| { kind: "pin-bottom"; until: "settle" | "next-user-input" }
|
||||
| { kind: "restore"; scrollTop: number }
|
||||
| { kind: "follow-cursor" }
|
||||
| { kind: "scroll-to-id"; id: StashpadId; align: "center" | "nearest" | "top" };
|
||||
// `align` is passed straight to scrollIntoView({ block }), so it must be
|
||||
// one of its valid values — "start" means top. ("top" was a long-standing
|
||||
// typo that silently no-op'd; esbuild doesn't typecheck so it slipped by.)
|
||||
| { kind: "scroll-to-id"; id: StashpadId; align: "start" | "center" | "end" | "nearest" };
|
||||
|
||||
export interface ViewConfigState {
|
||||
focusId: StashpadId;
|
||||
|
|
|
|||
177
src/view.ts
177
src/view.ts
|
|
@ -4,10 +4,11 @@ import {
|
|||
moment, setIcon,
|
||||
} from "obsidian";
|
||||
import {
|
||||
ROOT_ID, STASHPAD_VIEW_TYPE, RESERVED_FRONTMATTER, fmHasTag, fmAddTag, fmRemoveTag, parseAssignees,
|
||||
ROOT_ID, STASHPAD_VIEW_TYPE, RESERVED_FRONTMATTER, fmHasTag, fmAddTag, fmRemoveTag, parseAssignees, attachmentLinkPath,
|
||||
type StashpadId, type TimeFilter, type TreeNode, type ViewConfigState, type ViewMode, type ScrollPolicy,
|
||||
} from "./types";
|
||||
import { TreeIndex } from "./tree-index";
|
||||
import { perf } from "./perf";
|
||||
import { formatDateTime } from "./format";
|
||||
import { OrderStore } from "./order-store";
|
||||
import { SortStore, type SortMode, SORT_MODE_LABELS, SORT_MODES_ORDER } from "./sort-store";
|
||||
|
|
@ -653,6 +654,8 @@ export class StashpadView extends ItemView {
|
|||
this.listResizeObserver = null;
|
||||
this.stickyRowObserver?.disconnect();
|
||||
this.stickyRowObserver = null;
|
||||
this.bodyObserver?.disconnect();
|
||||
this.bodyObserver = null;
|
||||
this.composerNarrowObserver?.disconnect();
|
||||
this.composerNarrowObserver = null;
|
||||
this.focusedMiniObserver?.disconnect();
|
||||
|
|
@ -1299,12 +1302,13 @@ export class StashpadView extends ItemView {
|
|||
if (!fm || !Array.isArray(fm.attachments)) continue;
|
||||
for (const a of fm.attachments) {
|
||||
if (typeof a !== "string") continue;
|
||||
// attachments may be stored as bare path or with a leading slash;
|
||||
// resolve via Obsidian's linkpath resolver when possible, fall
|
||||
// back to literal path.
|
||||
const resolved = this.app.metadataCache.getFirstLinkpathDest(a, child.path);
|
||||
// attachments may be a wikilink ([[path]]), a bare path, or have a
|
||||
// leading slash; normalize to the linktext then resolve via
|
||||
// Obsidian, falling back to the literal path.
|
||||
const linktext = attachmentLinkPath(a);
|
||||
const resolved = this.app.metadataCache.getFirstLinkpathDest(linktext, child.path);
|
||||
if (resolved) out.add(resolved.path);
|
||||
else out.add(a);
|
||||
else out.add(linktext);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1371,6 +1375,12 @@ export class StashpadView extends ItemView {
|
|||
// per-row overflow memo (see getOrComputeRender). One layout read
|
||||
// instead of one per row.
|
||||
this.lastListWidth = list.clientWidth || this.lastListWidth;
|
||||
// 0.82.1: (re)arm the lazy-body observer for this paint. Cold rows
|
||||
// (no cached render) get a cheap title placeholder and only do the
|
||||
// expensive cachedRead + MarkdownRenderer once they scroll near the
|
||||
// viewport — the profile showed body reads at full-list scale were
|
||||
// ~97% of the time.
|
||||
this.armBodyObserver();
|
||||
if (focused.file && Platform.isMobile) {
|
||||
this.renderFocusedHeaderMini(list, focused);
|
||||
this.renderFocusedHeader(list, focused);
|
||||
|
|
@ -1714,7 +1724,9 @@ export class StashpadView extends ItemView {
|
|||
if (fallbackScrollTop > 0) list.scrollTop = fallbackScrollTop;
|
||||
}
|
||||
|
||||
private _renderT0: number | null = null;
|
||||
private render(policy?: ScrollPolicy): void {
|
||||
if (perf.enabled) this._renderT0 = performance.now();
|
||||
// 0.56.3: unannotated render() calls default to "preserve". That kills
|
||||
// the bouncing class of regressions where metadataCache-driven
|
||||
// re-renders (color change, frontmatter mod, fmSync rewrites) would
|
||||
|
|
@ -1914,6 +1926,7 @@ export class StashpadView extends ItemView {
|
|||
// reordered. Content-changed lets the panel refresh in place
|
||||
// while staying pinned to its displayed note. Genuine selection
|
||||
// changes fire from selectCursor / handleRowClick / navigateTo.
|
||||
if (this._renderT0 != null) { perf.record("render.total", performance.now() - this._renderT0); this._renderT0 = null; }
|
||||
this.plugin.notifyStashpadContentChanged();
|
||||
// 0.79.9: when this render auto-selected a newly-created note, that's
|
||||
// a real selection change — notify so the detail panel unlocks and
|
||||
|
|
@ -3992,6 +4005,11 @@ export class StashpadView extends ItemView {
|
|||
* per-row reflow thrash was the dominant cost of the "couple
|
||||
* seconds to render" lag. */
|
||||
private renderCache = new Map<string, { mtime: number; text: string; attachments: string[]; html: string; ovW?: number; ovV?: boolean }>();
|
||||
/** 0.82.1: lazy-body machinery. `bodyObserver` watches cold rows; when
|
||||
* one nears the viewport its deferred render closure (stored in
|
||||
* `lazyBodies`, keyed by the body container) runs once. */
|
||||
private bodyObserver: IntersectionObserver | null = null;
|
||||
private lazyBodies = new WeakMap<HTMLElement, () => void>();
|
||||
/** Width the list was last laid out at — the key for the overflow
|
||||
* memo above. Captured once per populateListBody (one read), not
|
||||
* per row. */
|
||||
|
|
@ -3999,24 +4017,71 @@ export class StashpadView extends ItemView {
|
|||
|
||||
private async getOrComputeRender(file: TFile): Promise<{ mtime: number; text: string; attachments: string[]; html: string; ovW?: number; ovV?: boolean }> {
|
||||
const cached = this.renderCache.get(file.path);
|
||||
if (cached && cached.mtime === file.stat.mtime) return cached;
|
||||
if (cached && cached.mtime === file.stat.mtime) { perf.record("render.row.cacheHit", 0); return cached; }
|
||||
// Cache miss / stale entry. Read + parse + render into a detached div
|
||||
// and stash the result before returning.
|
||||
const md = await this.app.vault.cachedRead(file);
|
||||
// and stash the result before returning. 0.81.1: split the body READ
|
||||
// (network I/O on a share) from the markdown RENDER (CPU) so the
|
||||
// profile shows which dominates.
|
||||
const md = await perf.timeAsync("render.row.read", () => this.app.vault.cachedRead(file));
|
||||
const raw = this.stripFrontmatter(md);
|
||||
const { text, attachments } = this.splitAttachments(raw);
|
||||
const detached = createDiv({ cls: "stashpad-note-text" });
|
||||
await MarkdownRenderer.render(this.app, text, detached, file.path, this as any);
|
||||
await perf.timeAsync("render.row.markdown", () => MarkdownRenderer.render(this.app, text, detached, file.path, this as any));
|
||||
const html = detached.innerHTML;
|
||||
const entry = { mtime: file.stat.mtime, text, attachments, html };
|
||||
this.renderCache.set(file.path, entry);
|
||||
return entry;
|
||||
}
|
||||
|
||||
/** (Re)create the lazy-body IntersectionObserver for the current paint.
|
||||
* Root is the view's scroll host; rootMargin pre-renders a screenful
|
||||
* above/below so scrolling rarely catches a placeholder. */
|
||||
private armBodyObserver(): void {
|
||||
this.bodyObserver?.disconnect();
|
||||
this.lazyBodies = new WeakMap();
|
||||
this.bodyObserver = new IntersectionObserver((entries) => {
|
||||
for (const e of entries) {
|
||||
if (!e.isIntersecting) continue;
|
||||
const el = e.target as HTMLElement;
|
||||
const fn = this.lazyBodies.get(el);
|
||||
this.bodyObserver?.unobserve(el);
|
||||
this.lazyBodies.delete(el);
|
||||
if (fn) fn();
|
||||
}
|
||||
}, { root: this.contentEl, rootMargin: "1400px 0px" });
|
||||
}
|
||||
|
||||
private hasFreshRenderCache(file: TFile): boolean {
|
||||
const c = this.renderCache.get(file.path);
|
||||
return !!c && c.mtime === file.stat.mtime;
|
||||
}
|
||||
|
||||
/** Public entry: render the body NOW if it's already cached (cheap), or
|
||||
* show a title placeholder and defer the expensive read+render until the
|
||||
* row scrolls into view. 0.82.1. */
|
||||
private renderNoteBody(
|
||||
container: HTMLElement,
|
||||
node: TreeNode,
|
||||
opts: { clamp?: boolean; toggleHost?: HTMLElement; toggleAnchor?: HTMLElement } = { clamp: true },
|
||||
): void {
|
||||
if (!node.file) return;
|
||||
// Warm rows (cached HTML) render instantly — no deferral needed. Cold
|
||||
// rows (the expensive cachedRead misses) get a placeholder + observer.
|
||||
if (this.hasFreshRenderCache(node.file) || !this.bodyObserver) {
|
||||
this.renderNoteBodyNow(container, node, opts);
|
||||
return;
|
||||
}
|
||||
container.empty();
|
||||
const ph = container.createDiv({ cls: "stashpad-note-text is-plain is-lazy-placeholder" });
|
||||
ph.textContent = this.titleForNode(node);
|
||||
this.lazyBodies.set(container, () => this.renderNoteBodyNow(container, node, opts));
|
||||
this.bodyObserver.observe(container);
|
||||
}
|
||||
|
||||
private renderNoteBodyNow(
|
||||
container: HTMLElement,
|
||||
node: TreeNode,
|
||||
opts: { clamp?: boolean; toggleHost?: HTMLElement; toggleAnchor?: HTMLElement } = { clamp: true },
|
||||
): void {
|
||||
if (!node.file) return;
|
||||
const file = node.file;
|
||||
|
|
@ -4554,15 +4619,19 @@ export class StashpadView extends ItemView {
|
|||
this.viewRoot.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
// ↑ at the very start of the textarea → jump out into the list, landing on
|
||||
// the LAST note (closest to composer) regardless of any prior cursor state.
|
||||
// Subsequent arrow ups within the list decrement normally.
|
||||
// ↑ at the very start of the textarea → jump out into the list.
|
||||
// 0.80.2: land on the LAST-FOCUSED note for this level (the one that
|
||||
// still has the ring), not always the bottommost — so escaping the
|
||||
// composer returns you to where you were. Falls back to the last
|
||||
// note when there's no remembered cursor.
|
||||
if (e.key === "ArrowUp" && ta.selectionStart === 0 && ta.selectionEnd === 0) {
|
||||
e.preventDefault();
|
||||
ta.blur();
|
||||
this.viewRoot.focus({ preventScroll: true });
|
||||
if (this.currentChildren.length > 0) {
|
||||
this.cursorIdx = this.currentChildren.length - 1;
|
||||
const lastId = this.lastCursorByFocus.get(this.focusId) ?? this.lastSelected;
|
||||
const idx = lastId ? this.currentChildren.findIndex((n) => n.id === lastId) : -1;
|
||||
this.cursorIdx = idx >= 0 ? idx : this.currentChildren.length - 1;
|
||||
this.selectCursor(false);
|
||||
}
|
||||
return;
|
||||
|
|
@ -4887,16 +4956,20 @@ export class StashpadView extends ItemView {
|
|||
// same 100–300ms regression we fixed for normal cursor nav in
|
||||
// 0.73.4. Now we just repaint the .is-pick-target class on
|
||||
// existing rows and scroll the new target into view.
|
||||
// 0.80.4: skip the notes being moved (the current selection) — you
|
||||
// can't nest them under themselves, so stepping onto them is wasted
|
||||
// motion. Both directions skip, so reversing also hops over the
|
||||
// selected run.
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
this.inListPicker.activeIdx = Math.min(this.currentChildren.length - 1, this.inListPicker.activeIdx + 1);
|
||||
this.inListPicker.activeIdx = this.nextPickableIdx(this.inListPicker.activeIdx, 1);
|
||||
this.repaintSelectionClasses();
|
||||
this.revealRowAt(this.inListPicker.activeIdx);
|
||||
return;
|
||||
}
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
this.inListPicker.activeIdx = Math.max(0, this.inListPicker.activeIdx - 1);
|
||||
this.inListPicker.activeIdx = this.nextPickableIdx(this.inListPicker.activeIdx, -1);
|
||||
this.repaintSelectionClasses();
|
||||
this.revealRowAt(this.inListPicker.activeIdx);
|
||||
return;
|
||||
|
|
@ -5018,6 +5091,11 @@ export class StashpadView extends ItemView {
|
|||
if (matchBinding(e, sb.toggleTask)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); void this.cmdToggleTask(); return; }
|
||||
if (matchBinding(e, sb.setDue)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.cmdSetDue(); return; }
|
||||
}
|
||||
// Jump to top/bottom: no selection required — only a non-empty list.
|
||||
if (this.currentChildren.length > 0) {
|
||||
if (matchBinding(e, sb.jumpToTop)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.jumpToTop(); return; }
|
||||
if (matchBinding(e, sb.jumpToBottom)) { e.preventDefault(); e.stopPropagation(); e.stopImmediatePropagation(); this.jumpToBottom(); return; }
|
||||
}
|
||||
// Allow E / T from focused-header context too (no selection / cursor required).
|
||||
const focused = this.tree.get(this.focusId);
|
||||
if (focused?.file) {
|
||||
|
|
@ -5031,6 +5109,19 @@ export class StashpadView extends ItemView {
|
|||
}
|
||||
};
|
||||
|
||||
/** 0.80.3: move the cursor to the first / last note in the current list
|
||||
* and reveal it. Single-select (no shift-range). */
|
||||
jumpToTop(): void {
|
||||
if (this.currentChildren.length === 0) return;
|
||||
this.cursorIdx = 0;
|
||||
this.selectCursor(false);
|
||||
}
|
||||
jumpToBottom(): void {
|
||||
if (this.currentChildren.length === 0) return;
|
||||
this.cursorIdx = this.currentChildren.length - 1;
|
||||
this.selectCursor(false);
|
||||
}
|
||||
|
||||
private selectCursor(shift: boolean): void {
|
||||
const node = this.currentChildren[this.cursorIdx];
|
||||
if (!node) return;
|
||||
|
|
@ -5750,11 +5841,28 @@ export class StashpadView extends ItemView {
|
|||
});
|
||||
}
|
||||
|
||||
/** 0.80.4: next index from `from` in `dir` whose note isn't part of the
|
||||
* current selection (the notes being moved — invalid as their own
|
||||
* parent). Stays put if there's no unselected note that way. */
|
||||
private nextPickableIdx(from: number, dir: 1 | -1): number {
|
||||
for (let i = from + dir; i >= 0 && i < this.currentChildren.length; i += dir) {
|
||||
const node = this.currentChildren[i];
|
||||
if (node && !this.selection.has(node.id)) return i;
|
||||
}
|
||||
return from;
|
||||
}
|
||||
|
||||
private cmdInListPicker(): void {
|
||||
if (this.currentChildren.length === 0) return;
|
||||
// Pre-select the note above the cursor (the most common nest target).
|
||||
// Falls back to index 0 when the cursor is already at the top.
|
||||
const start = this.cursorIdx > 0 ? this.cursorIdx - 1 : 0;
|
||||
let start = this.cursorIdx > 0 ? this.cursorIdx - 1 : 0;
|
||||
// 0.80.4: if that lands on a note being moved, hop to the nearest
|
||||
// unselected one (look up first, then down).
|
||||
if (this.currentChildren[start] && this.selection.has(this.currentChildren[start].id)) {
|
||||
const up = this.nextPickableIdx(start, -1);
|
||||
start = up !== start ? up : this.nextPickableIdx(start, 1);
|
||||
}
|
||||
this.inListPicker = { activeIdx: start };
|
||||
new Notice("Arrows to pick parent, Enter confirms, Esc cancels.");
|
||||
// Preserve scroll position across the activation render — the highlight is
|
||||
|
|
@ -6454,15 +6562,16 @@ export class StashpadView extends ItemView {
|
|||
this.lastSelected = kids[kids.length - 1].id;
|
||||
}
|
||||
}
|
||||
// 0.56.22: follow-cursor — we just set cursor to `cameFrom` (the
|
||||
// child we came from). It IS the right thing to scroll to.
|
||||
this.render({ kind: "follow-cursor" });
|
||||
// 0.80.3 / 0.82.4: pin the note we came from to the TOP of the view
|
||||
// (when it's still present in the parent list). NOTE: the align value
|
||||
// is passed to scrollIntoView({ block }), which only accepts
|
||||
// start/center/end/nearest — "top" was invalid and silently did
|
||||
// nothing (cursor set, but scroll stayed at 0). "start" = top.
|
||||
if (idx >= 0) this.render({ kind: "scroll-to-id", id: cameFrom, align: "start" });
|
||||
else this.render({ kind: "follow-cursor" });
|
||||
this.refreshHeaderTitle();
|
||||
// Always reveal — the cached scroll restore puts us approximately back,
|
||||
// but the cursor row (often the child we just left) can still hide
|
||||
// behind the composer when it sits at the bottom of a long list.
|
||||
// revealCursorRow is a no-op if the row is already comfortably visible.
|
||||
this.revealCursorRow();
|
||||
// Belt-and-suspenders reveal in the fallback case.
|
||||
if (idx < 0) this.revealCursorRow();
|
||||
}
|
||||
private openBookmarks(): void {
|
||||
const bookmarks = (this.app as any).internalPlugins?.plugins?.bookmarks?.instance?.items ?? [];
|
||||
|
|
@ -7947,7 +8056,8 @@ export class StashpadView extends ItemView {
|
|||
const fm = this.app.metadataCache.getFileCache(n.file)?.frontmatter;
|
||||
if (Array.isArray(fm?.attachments)) {
|
||||
for (const a of fm.attachments) {
|
||||
if (typeof a === "string" && a.trim()) attachments.push(a);
|
||||
// 0.79.18: entries may be wikilinks now — normalize to linktext.
|
||||
if (typeof a === "string" && a.trim()) attachments.push(attachmentLinkPath(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8462,7 +8572,12 @@ export class StashpadView extends ItemView {
|
|||
fmLines.push("---", body);
|
||||
try {
|
||||
const fullContent = fmLines.join("\n");
|
||||
await this.app.vault.create(path, fullContent);
|
||||
// 0.79.20: exempt our own new note from auto-import. On a slow
|
||||
// network drive the file's frontmatter may not have flushed when the
|
||||
// importer's create event fires, so its disk id-check would miss the
|
||||
// id and "import" the note into Home. Long TTL covers a laggy create.
|
||||
this.plugin.importService.suppress(path, 60000);
|
||||
await perf.timeAsync("write.createNote.file", () => this.app.vault.create(path, fullContent));
|
||||
try {
|
||||
const f = this.app.vault.getAbstractFileByPath(path);
|
||||
if (f && (f as any).extension === "md") {
|
||||
|
|
@ -9056,6 +9171,11 @@ export class StashpadView extends ItemView {
|
|||
this.knownBodies.set(file.path, body);
|
||||
if (prev === undefined) return; // first sighting — no contribution
|
||||
if (prev === body) return; // frontmatter-only write — skip
|
||||
// 0.79.19: never stamp during rebootstrap — its frontmatter writes and
|
||||
// the wikilink rewrites from slug-renames must not bump `modified` or
|
||||
// add contributors. knownBodies is already updated above, so the next
|
||||
// genuine edit is still detected correctly.
|
||||
if (this.plugin.rebootstrapInProgress) return;
|
||||
const author = this.currentAuthorLink();
|
||||
if (!author) return; // user opted out of stamping
|
||||
void this.ensureAuthorFile(author);
|
||||
|
|
@ -9361,7 +9481,8 @@ export class StashpadView extends ItemView {
|
|||
const fm = this.app.metadataCache.getFileCache(n.file)?.frontmatter;
|
||||
if (Array.isArray(fm?.attachments)) {
|
||||
for (const a of fm.attachments) {
|
||||
if (typeof a === "string" && a.trim()) attachments.push(a);
|
||||
// 0.79.18: entries may be wikilinks now — normalize to linktext.
|
||||
if (typeof a === "string" && a.trim()) attachments.push(attachmentLinkPath(a));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue