Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db92dabba5 | ||
|
|
2469a971ca | ||
|
|
a43bf05bf0 | ||
|
|
ffd9ebad7e | ||
|
|
d2100c8500 | ||
|
|
7e63d721bc | ||
|
|
2c774c51c6 | ||
|
|
8eba4527af | ||
|
|
181b3eb6c2 | ||
|
|
879d2f42c6 | ||
|
|
85ea26b57a | ||
|
|
800c81e598 | ||
|
|
30c3523722 | ||
|
|
761bf5b42e | ||
|
|
b10a7b1bfc | ||
|
|
313d86df23 | ||
|
|
95627cbb5c | ||
|
|
b9ea14f330 | ||
|
|
812d025a9d | ||
|
|
11cc2daa4b | ||
|
|
5c36f2aa71 | ||
|
|
987b1b9ed7 | ||
|
|
3ef0e14581 | ||
|
|
f98b3415bc | ||
|
|
16805c96aa | ||
|
|
9f2e0ba8b6 | ||
|
|
7b3609afbb | ||
|
|
84ff75f223 | ||
|
|
56dc0eefd7 | ||
|
|
eeea16531a | ||
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d | ||
|
|
93122ade7b | ||
|
|
80bdabaf1b | ||
|
|
cea98cffde | ||
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef | ||
|
|
000d68a0e7 | ||
|
|
d8c391a480 | ||
|
|
459af56564 | ||
|
|
9d82f2aed7 | ||
|
|
4fb99c7e39 | ||
|
|
92e8422cb0 | ||
|
|
5defddb35c | ||
|
|
dc19976568 | ||
|
|
bd59f6dbee | ||
|
|
26fa22afaa | ||
|
|
e2702bdd51 | ||
|
|
0d0ecee06e | ||
|
|
6115ad4ab6 | ||
|
|
a400eeefed | ||
|
|
f19ddee17d | ||
|
|
393505e1ac | ||
|
|
b87e8b8a38 | ||
|
|
5b62302b3a | ||
|
|
ee0ffdf121 | ||
|
|
89eea8e71b | ||
|
|
f27512c7ee | ||
|
|
eafd2b0a24 | ||
|
|
aeafd55491 | ||
|
|
ccc5e3b7c6 | ||
|
|
a28e514ed4 | ||
|
|
7c94370e48 | ||
|
|
ab39fc2ed6 | ||
|
|
621e2b31e0 | ||
|
|
ca3900815b | ||
|
|
b36ce9ed5b | ||
|
|
59677577ec | ||
|
|
71a0b9c0a0 | ||
|
|
3876b7db96 | ||
|
|
0436136533 | ||
|
|
139ecc8176 | ||
|
|
4b82d51f6c | ||
|
|
8ceb44df78 | ||
|
|
3b3e7fbbe6 | ||
|
|
7cbd112585 | ||
|
|
d275b0e783 | ||
|
|
fedbdfd2a1 |
@@ -1,5 +1,10 @@
|
||||
node_modules
|
||||
|
||||
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
@@ -8,9 +13,20 @@ node_modules
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
# Tauri
|
||||
/src-tauri/target
|
||||
/src-tauri/gen
|
||||
|
||||
# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested
|
||||
# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out.
|
||||
/src-tauri/gen/*
|
||||
!/src-tauri/gen/android/
|
||||
|
||||
# Never commit signing secrets (nested ignore misses keystore.properties)
|
||||
/src-tauri/gen/android/keystore.properties
|
||||
/src-tauri/gen/android/key.properties
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.2.8)
|
||||
## Download (v1.3.2)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -26,25 +26,97 @@ yay -S helixnotes-appimage-bin
|
||||
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||
```
|
||||
|
||||
#### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
helix-notes = {
|
||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
}
|
||||
};
|
||||
|
||||
outputs = {
|
||||
nixpkgs,
|
||||
helix-notes,
|
||||
...
|
||||
}: let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
|
||||
system = system;
|
||||
specialArgs = { inherit helix-notes; };
|
||||
|
||||
modules = [
|
||||
/path/to/configuration.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>configuration.nix</summary>
|
||||
|
||||
```nix
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
helix-notes,
|
||||
...
|
||||
}:
|
||||
{
|
||||
users.users.<USERNAME> = {
|
||||
packages = with pkgs; [
|
||||
(helix-notes.packages.${pkgs.stdenv.hostPlatform.system}.default)
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.2.8/HelixNotes_1.2.8_amd64.AppImage)
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_amd64.deb) (Ubuntu 22.04+)
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
[Download .dmg (Apple Silicon)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_aarch64.dmg) (M-series Macs)
|
||||
|
||||
[Download .dmg (Intel)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
|
||||
|
||||
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||
>
|
||||
> ```bash
|
||||
> xattr -cr /Applications/HelixNotes.app
|
||||
> ```
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_android.apk)
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,15 +125,23 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
|
||||
## Features
|
||||
|
||||
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
||||
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
|
||||
- `[[Wiki-links]]` and graph view
|
||||
- Full-text search (Tantivy)
|
||||
- Outline panel, daily notes with calendar view, tags, drag-and-drop
|
||||
- Math (KaTeX), PDF preview, Obsidian import
|
||||
- AI writing tools (Ollama / Anthropic / OpenAI)
|
||||
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
|
||||
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
|
||||
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
||||
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
||||
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
|
||||
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
|
||||
- Manual notebook sorting (drag to reorder above, into, or below)
|
||||
- External `.md` viewer mode with import-to-vault flow
|
||||
- PDF preview, Obsidian import, "Show in File Manager"
|
||||
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
|
||||
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
|
||||
- Version history with diffs, automatic backups
|
||||
- Multi-window, file associations, focus mode, view mode
|
||||
- Themes, accent colors, fonts
|
||||
- Local files, no cloud
|
||||
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
|
||||
- Local plain-text files, no company cloud
|
||||
|
||||
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||
|
||||
@@ -96,9 +176,10 @@ pnpm tauri build
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
- Internal note links ([[wiki-links]] open the linked note)
|
||||
- Mobile UI improvements
|
||||
- Window state persistence (desktop)
|
||||
@@ -1,12 +0,0 @@
|
||||
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
|
||||
|
||||
Features:
|
||||
- Markdown editor with toolbar, slash commands, and code highlighting
|
||||
- [[Wiki-links]] and graph view
|
||||
- Full-text search
|
||||
- Outline panel, daily notes, tags
|
||||
- Math (KaTeX) and PDF preview
|
||||
- AI writing tools (Ollama, Anthropic, OpenAI)
|
||||
- Version history with diffs
|
||||
- Obsidian import
|
||||
- Themes, accent colors, fonts
|
||||
@@ -1 +0,0 @@
|
||||
Local markdown note-taking app. No cloud, no lock-in.
|
||||
@@ -1 +0,0 @@
|
||||
HelixNotes
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
description = "HelixNotes (local Markdown notes app) with Nix flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo-tauri.hook
|
||||
|
||||
pnpmConfigHook
|
||||
pnpm
|
||||
nodejs
|
||||
|
||||
pkg-config
|
||||
jq
|
||||
moreutils
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
buildInputs = with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
];
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||
|
||||
# Deactivate the upstream update mechanism
|
||||
postPatch = ''
|
||||
jq '
|
||||
.bundle.createUpdaterArtifacts = false |
|
||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||
' \
|
||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "HelixNotes desktop application";
|
||||
homepage = "https://helixnotes.com/";
|
||||
license = licenses.agpl3Plus;
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
mainProgram = "helixnotes";
|
||||
};
|
||||
};
|
||||
|
||||
formatter = pkgs.alejandra;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -59,6 +59,7 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
|
||||
@@ -95,6 +95,9 @@ importers:
|
||||
'@types/markdown-it':
|
||||
specifier: ^14.1.2
|
||||
version: 14.1.2
|
||||
highlight.js:
|
||||
specifier: ^11.11.1
|
||||
version: 11.11.1
|
||||
katex:
|
||||
specifier: ^0.16.28
|
||||
version: 0.16.28
|
||||
|
||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.2.9"
|
||||
version = "1.3.3"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1861,6 +1861,7 @@ dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1868,6 +1869,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sys-locale",
|
||||
"tantivy",
|
||||
"tauri",
|
||||
@@ -1880,6 +1882,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 2.4.2",
|
||||
@@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3612,6 +3615,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -5911,6 +5923,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.2.9"
|
||||
version = "1.3.3"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -38,8 +38,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||
futures = "0.3"
|
||||
rayon = "1"
|
||||
quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,19 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
@@ -0,0 +1,6 @@
|
||||
/src/main/java/com/helixnotes/app/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
@@ -0,0 +1,92 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propFile = rootProject.file("keystore.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "com.helixnotes.app"
|
||||
|
||||
if (keystoreProperties.containsKey("storeFile")) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "com.helixnotes.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
if (signingConfigs.names.contains("release")) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
@@ -0,0 +1,22 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Preserve JS interface annotations
|
||||
-keepattributes JavascriptInterface
|
||||
|
||||
# Keep our custom WebView JS bridge and all its public methods
|
||||
-keep class com.helixnotes.app.StorageBridge {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Keep MainActivity public methods (called from StorageBridge)
|
||||
-keep class com.helixnotes.app.MainActivity {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Ignore missing java.beans classes (referenced by Jackson but not available on Android)
|
||||
-dontwarn java.beans.**
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@style/Theme.helixnotes"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.helixnotes.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private var webView: WebView? = null
|
||||
|
||||
companion object {
|
||||
private const val STORAGE_PERMISSION_REQUEST_CODE = 1001
|
||||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
super.onWebViewCreate(webView)
|
||||
this.webView = webView
|
||||
webView.addJavascriptInterface(StorageBridge(this), "Android")
|
||||
// Render text at the size CSS specifies, ignoring the device's system font-size
|
||||
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
|
||||
webView.settings.textZoom = 100
|
||||
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
|
||||
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
|
||||
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
|
||||
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (hasStoragePermission()) {
|
||||
webView?.evaluateJavascript(
|
||||
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasStoragePermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Android 11+ (API 30+): "All files access" special permission.
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
// Android 7-10 (API 24-29): legacy runtime storage permission.
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStoragePermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (Environment.isExternalStorageManager()) return
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
startActivity(fallback)
|
||||
} catch (_: Exception) {
|
||||
val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
details.data = Uri.parse("package:$packageName")
|
||||
startActivity(details)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Android 7-10: request the legacy runtime permission directly.
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
),
|
||||
STORAGE_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) {
|
||||
webView?.evaluateJavascript(
|
||||
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareVaultDir(path: String): Boolean {
|
||||
return try {
|
||||
val dir = File(path)
|
||||
dir.mkdirs()
|
||||
File(dir, ".helixnotes/trash").mkdirs()
|
||||
File(dir, ".helixnotes/attachments").mkdirs()
|
||||
dir.exists() && dir.canWrite()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun openFile(path: String) {
|
||||
try {
|
||||
val file = File(path)
|
||||
if (!file.exists()) return
|
||||
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
|
||||
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HelixNotes", "Failed to open file: $path", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StorageBridge(private val activity: MainActivity) {
|
||||
@JavascriptInterface
|
||||
fun hasStoragePermission(): Boolean {
|
||||
return activity.hasStoragePermission()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestStoragePermission() {
|
||||
activity.runOnUiThread {
|
||||
activity.requestStoragePermission()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun prepareVaultDir(path: String): Boolean {
|
||||
return activity.prepareVaultDir(path)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openFile(path: String) {
|
||||
activity.runOnUiThread {
|
||||
activity.openFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#5B6ABF"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme (dark mode). -->
|
||||
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">#1a1b26</item>
|
||||
<item name="android:navigationBarColor">#1a1b26</item>
|
||||
<item name="android:statusBarColor">#1a1b26</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">HelixNotes</string>
|
||||
<string name="main_activity_title">HelixNotes</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme (light mode). -->
|
||||
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">#ffffff</item>
|
||||
<item name="android:navigationBarColor">#ffffff</item>
|
||||
<item name="android:statusBarColor">#ffffff</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,22 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """pnpm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.get
|
||||
|
||||
const val TASK_GROUP = "rust"
|
||||
|
||||
open class Config {
|
||||
lateinit var rootDirRel: String
|
||||
}
|
||||
|
||||
open class RustPlugin : Plugin<Project> {
|
||||
private lateinit var config: Config
|
||||
|
||||
override fun apply(project: Project) = with(project) {
|
||||
config = extensions.create("rust", Config::class.java)
|
||||
|
||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||
|
||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||
|
||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
@Suppress("UnstableApiUsage")
|
||||
flavorDimensions.add("abi")
|
||||
productFlavors {
|
||||
create("universal") {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters += abiList
|
||||
}
|
||||
}
|
||||
defaultArchList.forEachIndexed { index, arch ->
|
||||
create(arch) {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters.add(defaultAbiList[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
for (profile in listOf("debug", "release")) {
|
||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||
val buildTask = tasks.maybeCreate(
|
||||
"rustBuildUniversal$profileCapitalized",
|
||||
DefaultTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for all targets"
|
||||
}
|
||||
|
||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||
|
||||
for (targetPair in targetsList.withIndex()) {
|
||||
val targetName = targetPair.value
|
||||
val targetArch = archList[targetPair.index]
|
||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||
val targetBuildTask = project.tasks.maybeCreate(
|
||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||
BuildTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for $targetArch"
|
||||
rootDirRel = config.rootDirRel
|
||||
target = targetName
|
||||
release = profile == "release"
|
||||
}
|
||||
|
||||
buildTask.dependsOn(targetBuildTask)
|
||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||
targetBuildTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
@@ -0,0 +1,6 @@
|
||||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"themes": [
|
||||
{
|
||||
"colors": {
|
||||
"bg_active": "#373a52",
|
||||
"bg_editor": "#1a1b26",
|
||||
"bg_hover": "#2f3145",
|
||||
"bg_primary": "#1a1b26",
|
||||
"bg_secondary": "#1f2029",
|
||||
"bg_tertiary": "#2a2b3d",
|
||||
"border_color": "#2f3145",
|
||||
"text_primary": "#c8cde4",
|
||||
"text_secondary": "#6b7094"
|
||||
},
|
||||
"id": "custom-1782146306429",
|
||||
"is_dark": true,
|
||||
"name": "My theme"
|
||||
}
|
||||
],
|
||||
"version": 1
|
||||
}
|
||||
@@ -21,6 +21,8 @@ pub fn ai_request(
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
||||
let result = match provider.as_str() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
@@ -40,7 +42,7 @@ pub fn ai_request(
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
None,
|
||||
key_opt,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
@@ -48,6 +50,24 @@ pub fn ai_request(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"openai_compatible" => {
|
||||
let url = base_url.as_deref().unwrap_or("");
|
||||
if url.is_empty() {
|
||||
Err("No base URL configured for OpenAI Compatible provider".to_string())
|
||||
} else {
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
key_opt,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
stream_anthropic(
|
||||
&app,
|
||||
@@ -74,6 +94,13 @@ pub fn ai_request(
|
||||
});
|
||||
}
|
||||
|
||||
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
|
||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||
fn normalize_openai_base(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
@@ -353,12 +380,21 @@ pub async fn test_connection(
|
||||
model: &str,
|
||||
base_url: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
||||
match provider {
|
||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||
"ollama" => {
|
||||
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
|
||||
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
|
||||
test_openai(&url, None, model).await
|
||||
test_openai(&url, key_opt, model).await
|
||||
}
|
||||
"openai_compatible" => {
|
||||
let url = base_url.unwrap_or("");
|
||||
if url.is_empty() {
|
||||
return Err("No base URL configured for OpenAI Compatible provider".to_string());
|
||||
}
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
test_openai(&url, key_opt, model).await
|
||||
}
|
||||
_ => test_anthropic(api_key, model).await,
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ mod commands;
|
||||
mod history;
|
||||
mod search;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod types;
|
||||
mod vault;
|
||||
|
||||
@@ -11,7 +12,7 @@ use state::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
@@ -20,18 +21,38 @@ use tauri::{
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
let config = commands::load_app_config();
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let show_tray = config.show_tray_icon;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let close_to_tray = config.close_to_tray && show_tray;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
// Inject the compile-time platform so the frontend never sniffs the (sometimes
|
||||
// mobile-looking) WebKitGTK user-agent. (#63)
|
||||
let platform_init = format!(
|
||||
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
|
||||
cfg!(mobile),
|
||||
cfg!(target_os = "android"),
|
||||
cfg!(target_os = "ios"),
|
||||
);
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(
|
||||
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
|
||||
.js_init_script(platform_init)
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -44,28 +65,28 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
|
||||
// On Android, set config dir from Tauri's path resolver, then reload config
|
||||
#[cfg(target_os = "android")]
|
||||
// On mobile, set config dir from Tauri's path resolver, then reload config
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
if let Ok(config_dir) = app.path().config_dir() {
|
||||
commands::set_android_config_dir(config_dir);
|
||||
commands::set_mobile_config_dir(config_dir);
|
||||
} else if let Ok(data_dir) = app.path().data_dir() {
|
||||
commands::set_android_config_dir(data_dir);
|
||||
commands::set_mobile_config_dir(data_dir);
|
||||
}
|
||||
// Reload config now that the Android config dir is available
|
||||
// Reload config now that the mobile config dir is available
|
||||
let reloaded = commands::load_app_config();
|
||||
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
|
||||
*cfg = reloaded;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
}
|
||||
|
||||
// Check CLI args for a .md file path on initial launch
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let file_arg = std::env::args().skip(1).find(|a| {
|
||||
let a = a.trim();
|
||||
@@ -91,12 +112,19 @@ pub fn run() {
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_accent_color,
|
||||
commands::save_custom_theme,
|
||||
commands::delete_custom_theme,
|
||||
commands::export_custom_theme,
|
||||
commands::import_custom_themes,
|
||||
commands::set_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
commands::set_content_width,
|
||||
commands::get_notebooks,
|
||||
commands::count_root_notes,
|
||||
commands::create_notebook,
|
||||
@@ -114,6 +142,10 @@ pub fn run() {
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
commands::set_task_priority,
|
||||
commands::set_task_due,
|
||||
commands::search_notes,
|
||||
commands::reindex,
|
||||
commands::get_trash,
|
||||
@@ -135,6 +167,8 @@ pub fn run() {
|
||||
commands::remove_quick_access,
|
||||
commands::reorder_quick_access,
|
||||
commands::get_vault_stats,
|
||||
commands::find_orphaned_attachments,
|
||||
commands::trash_orphaned_attachments,
|
||||
commands::import_obsidian,
|
||||
commands::open_file,
|
||||
commands::open_url,
|
||||
@@ -152,7 +186,11 @@ pub fn run() {
|
||||
commands::set_ai_settings,
|
||||
commands::test_ai_connection,
|
||||
commands::ai_ask,
|
||||
commands::set_sync_settings,
|
||||
commands::test_sync_connection,
|
||||
commands::sync_now,
|
||||
commands::get_install_type,
|
||||
commands::is_mobile_platform,
|
||||
commands::get_pending_open_file,
|
||||
])
|
||||
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
|
||||
@@ -229,7 +267,7 @@ pub fn run() {
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
// Always show/focus the main window
|
||||
@@ -290,7 +328,7 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
@@ -5,15 +5,163 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tantivy::collector::TopDocs;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
use tantivy::directory::MmapDirectory;
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
use tantivy::directory::RamDirectory;
|
||||
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
|
||||
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
|
||||
use tantivy::schema::*;
|
||||
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
|
||||
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Bumped whenever the index schema or tokenizer changes, so the on-disk index is
|
||||
/// wiped and rebuilt once on the next vault open (the index is derived from the
|
||||
/// notes, so this never loses data).
|
||||
const INDEX_SCHEMA_VERSION: &str = "2-cjk-bigram";
|
||||
|
||||
/// Per-vault search index dir in local app-data, kept out of the (possibly synced) vault.
|
||||
#[cfg(desktop)]
|
||||
fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
/// True for characters in the CJK / Japanese / Korean blocks, which are written
|
||||
/// without spaces between words. These get uni/bi-gram tokenized so substring
|
||||
/// search works; everything else keeps the default word-splitting behaviour.
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x3400..=0x4DBF // CJK Unified Ideographs Extension A
|
||||
| 0x4E00..=0x9FFF // CJK Unified Ideographs (Han)
|
||||
| 0x3040..=0x309F // Hiragana
|
||||
| 0x30A0..=0x30FF // Katakana
|
||||
| 0xAC00..=0xD7AF // Hangul Syllables
|
||||
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
|
||||
| 0xFF00..=0xFFEF // Halfwidth and Fullwidth Forms
|
||||
)
|
||||
}
|
||||
|
||||
/// Tokenize `text` so that CJK runs become overlapping unigrams + bigrams (recall
|
||||
/// over precision: a query for any substring of a CJK run will match), while runs
|
||||
/// of other alphanumeric characters become a single word token (same as tantivy's
|
||||
/// SimpleTokenizer, so Latin/English indexing is unchanged). Lowercasing is applied
|
||||
/// by a LowerCaser filter in the analyzer, not here.
|
||||
fn cjk_tokens(text: &str) -> Vec<Token> {
|
||||
let mut tokens: Vec<Token> = Vec::new();
|
||||
let mut position: usize = 0;
|
||||
let mut cjk_run: Vec<(usize, char)> = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut word_start: usize = 0;
|
||||
|
||||
macro_rules! flush_word {
|
||||
() => {{
|
||||
if !word.is_empty() {
|
||||
let len = word.len();
|
||||
tokens.push(Token {
|
||||
offset_from: word_start,
|
||||
offset_to: word_start + len,
|
||||
position,
|
||||
text: std::mem::take(&mut word),
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! flush_cjk {
|
||||
() => {{
|
||||
let n = cjk_run.len();
|
||||
for i in 0..n {
|
||||
let (off, ch) = cjk_run[i];
|
||||
tokens.push(Token {
|
||||
offset_from: off,
|
||||
offset_to: off + ch.len_utf8(),
|
||||
position,
|
||||
text: ch.to_string(),
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let (off1, c1) = cjk_run[i];
|
||||
let (off2, c2) = cjk_run[i + 1];
|
||||
let mut s = String::with_capacity(c1.len_utf8() + c2.len_utf8());
|
||||
s.push(c1);
|
||||
s.push(c2);
|
||||
tokens.push(Token {
|
||||
offset_from: off1,
|
||||
offset_to: off2 + c2.len_utf8(),
|
||||
position,
|
||||
text: s,
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
cjk_run.clear();
|
||||
}};
|
||||
}
|
||||
|
||||
for (offset, c) in text.char_indices() {
|
||||
if is_cjk(c) {
|
||||
flush_word!();
|
||||
cjk_run.push((offset, c));
|
||||
} else if c.is_alphanumeric() {
|
||||
flush_cjk!();
|
||||
if word.is_empty() {
|
||||
word_start = offset;
|
||||
}
|
||||
word.push(c);
|
||||
} else {
|
||||
flush_cjk!();
|
||||
flush_word!();
|
||||
}
|
||||
}
|
||||
flush_cjk!();
|
||||
flush_word!();
|
||||
let _ = position; // the final flush increments position but nothing reads it after
|
||||
tokens
|
||||
}
|
||||
|
||||
/// A pre-computed token stream (all tokens collected up front by `cjk_tokens`).
|
||||
struct PreTokenizedStream {
|
||||
tokens: Vec<Token>,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl TokenStream for PreTokenizedStream {
|
||||
fn advance(&mut self) -> bool {
|
||||
if self.idx < self.tokens.len() {
|
||||
self.idx += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn token(&self) -> &Token {
|
||||
&self.tokens[self.idx - 1]
|
||||
}
|
||||
fn token_mut(&mut self) -> &mut Token {
|
||||
&mut self.tokens[self.idx - 1]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CjkTokenizer;
|
||||
|
||||
impl Tokenizer for CjkTokenizer {
|
||||
type TokenStream<'a> = PreTokenizedStream;
|
||||
fn token_stream<'a>(&'a mut self, text: &'a str) -> PreTokenizedStream {
|
||||
PreTokenizedStream {
|
||||
tokens: cjk_tokens(text),
|
||||
idx: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchIndex {
|
||||
index: Index,
|
||||
writer: Mutex<Option<IndexWriter>>,
|
||||
@@ -29,29 +177,70 @@ impl SearchIndex {
|
||||
pub fn new(vault_path: &str) -> Result<Self, String> {
|
||||
let mut schema_builder = Schema::builder();
|
||||
let path_field = schema_builder.add_text_field("path", STRING | STORED);
|
||||
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
||||
let body_field = schema_builder.add_text_field("body", TEXT);
|
||||
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
|
||||
// CJK-aware tokenizer for the indexed text fields (keeps Latin/English
|
||||
// behaviour identical; adds substring matching for Chinese/Japanese/Korean).
|
||||
let cjk_indexing = TextFieldIndexing::default()
|
||||
.set_tokenizer("cjk")
|
||||
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
|
||||
let cjk_text = TextOptions::default().set_indexing_options(cjk_indexing.clone());
|
||||
let cjk_text_stored = cjk_text.clone().set_stored();
|
||||
let title_field = schema_builder.add_text_field("title", cjk_text_stored.clone());
|
||||
let body_field = schema_builder.add_text_field("body", cjk_text);
|
||||
let tags_field = schema_builder.add_text_field("tags", cjk_text_stored);
|
||||
let schema = schema_builder.build();
|
||||
|
||||
// Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem)
|
||||
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
|
||||
// Desktop: use mmap directory for persistent index on disk
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
let index = {
|
||||
let dir = RamDirectory::create();
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let index = {
|
||||
let index_dir = helixnotes_dir(vault_path).join("search_index");
|
||||
let base = vault_index_base(vault_path)
|
||||
.unwrap_or_else(|| helixnotes_dir(vault_path).join("search_index"));
|
||||
let index_dir = base.join("index");
|
||||
let version_path = base.join("version");
|
||||
|
||||
// Remove the old in-vault index so it stops syncing.
|
||||
let hn = helixnotes_dir(vault_path);
|
||||
let _ = fs::remove_dir_all(hn.join("search_index"));
|
||||
let _ = fs::remove_file(hn.join("search_index.version"));
|
||||
|
||||
// One-time wipe when the schema/tokenizer version changes; rebuild() repopulates.
|
||||
let version_ok = fs::read_to_string(&version_path)
|
||||
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
|
||||
.unwrap_or(false);
|
||||
if !version_ok {
|
||||
let _ = fs::remove_dir_all(&index_dir);
|
||||
}
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
let idx = match Index::open_or_create(dir, schema.clone()) {
|
||||
Ok(idx) => idx,
|
||||
Err(_) => {
|
||||
// Schema mismatch (older index) or corruption: wipe and recreate.
|
||||
let _ = fs::remove_dir_all(&index_dir);
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
let _ = fs::write(&version_path, INDEX_SCHEMA_VERSION);
|
||||
idx
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
// Register the CJK-aware tokenizer (in-memory; must be done on every open,
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
let heap_size = 15_000_000;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let heap_size = 50_000_000;
|
||||
|
||||
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
|
||||
@@ -153,28 +342,50 @@ impl SearchIndex {
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let terms: Vec<String> = query_str
|
||||
.split_whitespace()
|
||||
.map(|t| t.to_lowercase())
|
||||
.collect();
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
let mut analyzer = self
|
||||
.index
|
||||
.tokenizers()
|
||||
.get("cjk")
|
||||
.ok_or("cjk tokenizer not registered")?;
|
||||
let terms: Vec<String> = {
|
||||
let mut out = Vec::new();
|
||||
let mut stream = analyzer.token_stream(query_str);
|
||||
while stream.advance() {
|
||||
out.push(stream.token().text.clone());
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
// For each search term, create prefix + fuzzy queries across all fields (OR),
|
||||
// then AND all terms together
|
||||
// For each term, OR queries across all fields, then AND all terms together.
|
||||
// CJK tokens (uni/bigrams) already encode segmentation -> exact term match.
|
||||
// Latin tokens keep prefix + fuzzy matching (unchanged English behaviour).
|
||||
let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
|
||||
.iter()
|
||||
.map(|term| {
|
||||
let is_cjk_term = term.chars().any(is_cjk);
|
||||
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
|
||||
.iter()
|
||||
.flat_map(|&field| {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
true,
|
||||
));
|
||||
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
|
||||
if is_cjk_term {
|
||||
let exact: Box<dyn Query> = Box::new(TermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
IndexRecordOption::WithFreqs,
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
true,
|
||||
));
|
||||
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct AppState {
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
// WebDAV sync provider.
|
||||
//
|
||||
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
|
||||
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
|
||||
// records what was in sync last time, so we can do a three-way diff (local vs remote
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{Emitter, Manager};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ───────────────────────── config ─────────────────────────
|
||||
|
||||
pub struct WebdavConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct SyncSummary {
|
||||
pub uploaded: u32,
|
||||
pub downloaded: u32,
|
||||
pub deleted_local: u32,
|
||||
pub deleted_remote: u32,
|
||||
pub conflicts: u32,
|
||||
}
|
||||
|
||||
// ───────────────────────── manifest ─────────────────────────
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct SyncManifest {
|
||||
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct ManifestEntry {
|
||||
local_hash: String, // sha256 of local content at last sync
|
||||
remote_etag: String, // remote etag at last sync
|
||||
}
|
||||
|
||||
fn manifest_path(vault: &str) -> PathBuf {
|
||||
helixnotes_dir(vault).join("sync_state.json")
|
||||
}
|
||||
|
||||
fn load_manifest(vault: &str) -> SyncManifest {
|
||||
fs::read_to_string(manifest_path(vault))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_manifest(vault: &str, m: &SyncManifest) {
|
||||
if let Ok(s) = serde_json::to_string(m) {
|
||||
let _ = fs::write(manifest_path(vault), s);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── helpers ─────────────────────────
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
let digest = h.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in digest {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
fn encode_path(relpath: &str) -> String {
|
||||
relpath
|
||||
.split('/')
|
||||
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
|
||||
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
|
||||
let (dir, name) = match relpath.rfind('/') {
|
||||
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
|
||||
None => ("", relpath),
|
||||
};
|
||||
let (stem, ext) = match name.rfind('.') {
|
||||
Some(i) => (&name[..i], &name[i..]),
|
||||
None => (name, ""),
|
||||
};
|
||||
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
|
||||
}
|
||||
|
||||
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let target = vault.join(relpath);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::write(&target, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
struct LocalFile {
|
||||
hash: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
|
||||
let vault_path = Path::new(vault);
|
||||
let mut map = HashMap::new();
|
||||
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let p = entry.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = match p.strip_prefix(vault_path) {
|
||||
Ok(r) => r.to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !is_synced_relpath(&rel) {
|
||||
continue;
|
||||
}
|
||||
let bytes = match fs::read(p) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
map.insert(
|
||||
rel,
|
||||
LocalFile {
|
||||
hash: sha256_hex(&bytes),
|
||||
path: p.to_path_buf(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ───────────────────────── WebDAV client ─────────────────────────
|
||||
|
||||
struct RemoteEntry {
|
||||
relpath: String,
|
||||
etag: String,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
struct WebdavClient {
|
||||
base: String, // normalized, no trailing slash
|
||||
base_path: String, // path component of base, no trailing slash (percent-encoded)
|
||||
client: reqwest::blocking::Client,
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
impl WebdavClient {
|
||||
fn new(cfg: WebdavConfig) -> Result<Self, String> {
|
||||
let base = cfg.url.trim().trim_end_matches('/').to_string();
|
||||
if !base.starts_with("http://") && !base.starts_with("https://") {
|
||||
return Err("WebDAV URL must start with http:// or https://".to_string());
|
||||
}
|
||||
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
|
||||
let base_path = base_url.path().trim_end_matches('/').to_string();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
base,
|
||||
base_path,
|
||||
client,
|
||||
user: cfg.username,
|
||||
pass: cfg.password,
|
||||
})
|
||||
}
|
||||
|
||||
fn url_for(&self, relpath: &str) -> String {
|
||||
if relpath.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, encode_path(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||
let resp = self
|
||||
.client
|
||||
.request(method, self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.header("Depth", depth)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.body(body)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err("Authentication failed (check username / app password)".to_string());
|
||||
}
|
||||
if status.as_u16() == 404 {
|
||||
return Err("WebDAV path not found (check the URL)".to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
|
||||
}
|
||||
let text = resp.text().map_err(|e| e.to_string())?;
|
||||
parse_multistatus(&text, &self.base_path)
|
||||
}
|
||||
|
||||
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
|
||||
/// (Depth: infinity is disabled on some servers, so we walk instead).
|
||||
fn list(&self) -> Result<HashMap<String, String>, String> {
|
||||
let mut files = HashMap::new();
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut queue: Vec<String> = vec![String::new()];
|
||||
while let Some(dir) = queue.pop() {
|
||||
if !visited.insert(dir.clone()) {
|
||||
continue;
|
||||
}
|
||||
for e in self.propfind(&dir, "1")? {
|
||||
if e.relpath == dir {
|
||||
continue; // the collection itself
|
||||
}
|
||||
if e.is_dir {
|
||||
if !visited.contains(&e.relpath) {
|
||||
queue.push(e.relpath);
|
||||
}
|
||||
} else {
|
||||
files.insert(e.relpath, e.etag);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
|
||||
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.put(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(normalize_etag))
|
||||
}
|
||||
|
||||
fn delete(&self, relpath: &str) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.delete(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status();
|
||||
if s.is_success() || s.as_u16() == 404 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
|
||||
}
|
||||
}
|
||||
|
||||
fn mkcol(&self, relpath: &str) -> Result<(), String> {
|
||||
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
|
||||
let resp = self
|
||||
.client
|
||||
.request(method, self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status().as_u16();
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||
if resp.status().is_success() || s == 405 || s == 301 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure every ancestor collection of `relpath` exists on the remote.
|
||||
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
|
||||
let parts: Vec<&str> = relpath.split('/').collect();
|
||||
if parts.len() <= 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cur = String::new();
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
if !cur.is_empty() {
|
||||
cur.push('/');
|
||||
}
|
||||
cur.push_str(part);
|
||||
if created.insert(cur.clone()) {
|
||||
self.mkcol(&cur)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||
/// to keep it consistent with future listings. (issue #147)
|
||||
fn etag_for(&self, relpath: &str) -> String {
|
||||
self.propfind(relpath, "0")
|
||||
.ok()
|
||||
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||
fn upload(
|
||||
&self,
|
||||
relpath: &str,
|
||||
bytes: &[u8],
|
||||
created: &mut HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
self.ensure_dirs(relpath, created)?;
|
||||
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||
Ok(self.etag_for(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a WebDAV `href` to a vault-relative path (forward slash).
|
||||
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||
/// wipes the local copy. (issue #102)
|
||||
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
reqwest::Url::parse(href)
|
||||
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||
.path()
|
||||
.to_string()
|
||||
} else {
|
||||
href.to_string()
|
||||
};
|
||||
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||
.into_owned();
|
||||
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||
.into_owned();
|
||||
let rest = match path.strip_prefix(&base) {
|
||||
Some(r) => r.trim_start_matches('/'),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||
Aborting before deleting anything locally - please report this URL."
|
||||
))
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return Ok(None); // the base collection itself
|
||||
}
|
||||
Ok(Some(rest.to_string()))
|
||||
}
|
||||
|
||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Cap {
|
||||
None,
|
||||
Href,
|
||||
Etag,
|
||||
}
|
||||
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut entries = Vec::new();
|
||||
let mut href: Option<String> = None;
|
||||
let mut etag: Option<String> = None;
|
||||
let mut is_dir = false;
|
||||
let mut cap = Cap::None;
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(e)) => match e.local_name().as_ref() {
|
||||
b"response" => {
|
||||
href = None;
|
||||
etag = None;
|
||||
is_dir = false;
|
||||
}
|
||||
b"href" => {
|
||||
cap = Cap::Href;
|
||||
buf.clear();
|
||||
}
|
||||
b"getetag" => {
|
||||
cap = Cap::Etag;
|
||||
buf.clear();
|
||||
}
|
||||
b"collection" => is_dir = true,
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Empty(e)) => {
|
||||
if e.local_name().as_ref() == b"collection" {
|
||||
is_dir = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => match e.local_name().as_ref() {
|
||||
b"href" => {
|
||||
href = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"getetag" => {
|
||||
etag = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"response" => {
|
||||
if let Some(h) = href.take() {
|
||||
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||
entries.push(RemoteEntry {
|
||||
relpath: rel,
|
||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("XML parse error: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// ───────────────────────── sync engine ─────────────────────────
|
||||
|
||||
fn apply_changes(
|
||||
client: &WebdavClient,
|
||||
vault: &str,
|
||||
local: &HashMap<String, LocalFile>,
|
||||
remote: &HashMap<String, String>,
|
||||
manifest: &SyncManifest,
|
||||
) -> Result<(SyncManifest, SyncSummary), String> {
|
||||
let vault_path = Path::new(vault);
|
||||
let mut new_m = SyncManifest::default();
|
||||
let mut sum = SyncSummary::default();
|
||||
let mut created: HashSet<String> = HashSet::new();
|
||||
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
|
||||
|
||||
let mut keys: HashSet<&String> = HashSet::new();
|
||||
keys.extend(local.keys());
|
||||
keys.extend(remote.keys());
|
||||
keys.extend(manifest.files.keys());
|
||||
|
||||
for key in keys {
|
||||
let l = local.get(key);
|
||||
let r = remote.get(key);
|
||||
let m = manifest.files.get(key);
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
} else if local_changed && !remote_changed {
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
} else if !local_changed && remote_changed {
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
} else {
|
||||
// both changed: compare contents; identical => no real conflict.
|
||||
let rbytes = client.get(key)?;
|
||||
let rhash = sha256_hex(&rbytes);
|
||||
if rhash == lf.hash {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// keep-both: remote becomes canonical; local saved as a conflict copy.
|
||||
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
write_local(vault_path, key, &rbytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: rhash,
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
let ckey = conflict_relpath(key, &stamp);
|
||||
write_local(vault_path, &ckey, &lbytes)?;
|
||||
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
ckey,
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&lbytes),
|
||||
remote_etag: cetag,
|
||||
},
|
||||
);
|
||||
sum.conflicts += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(lf), None) => match m {
|
||||
None => {
|
||||
// new local -> upload
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
}
|
||||
Some(me) => {
|
||||
if me.local_hash != lf.hash {
|
||||
// remote deleted but local edited -> resurrect (upload)
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
} else {
|
||||
// remote deleted, local unchanged -> delete local
|
||||
let _ = fs::remove_file(&lf.path);
|
||||
sum.deleted_local += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
(None, Some(re)) => match m {
|
||||
None => {
|
||||
// new remote -> download
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
}
|
||||
Some(me) => {
|
||||
if &me.remote_etag != re {
|
||||
// local deleted but remote changed -> resurrect (download)
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
} else {
|
||||
// local deleted, remote unchanged -> delete remote
|
||||
client.delete(key)?;
|
||||
sum.deleted_remote += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
// gone on both sides -> drop from manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((new_m, sum))
|
||||
}
|
||||
|
||||
/// Validate the connection (auth + URL) with a cheap PROPFIND.
|
||||
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
let client = WebdavClient::new(cfg)?;
|
||||
client.propfind("", "0")?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// rebuilds the search index. Returns a summary of what changed.
|
||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
let client = WebdavClient::new(cfg)?;
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
|
||||
let remote: HashMap<String, String> = client
|
||||
.list()?
|
||||
.into_iter()
|
||||
.filter(|(k, _)| is_synced_relpath(k))
|
||||
.collect();
|
||||
let local = scan_local(&vault)?;
|
||||
let manifest = load_manifest(&vault);
|
||||
|
||||
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||
if remote.is_empty() && !manifest.files.is_empty() {
|
||||
return Err(
|
||||
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||
even though files were synced before, so no local files were deleted. This usually \
|
||||
means the server reports paths in a format the app can't read yet - please report \
|
||||
your WebDAV provider and folder URL."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||
state.importing.store(true, Ordering::Relaxed);
|
||||
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
|
||||
state.importing.store(false, Ordering::Relaxed);
|
||||
|
||||
let (new_manifest, summary) = applied?;
|
||||
save_manifest(&vault, &new_manifest);
|
||||
|
||||
// Re-index after files changed (search is non-critical; ignore failures).
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.rebuild(&vault);
|
||||
}
|
||||
}
|
||||
|
||||
// The watcher was muted during apply, so nudge the UI to refresh its lists.
|
||||
let _ = app.emit(
|
||||
"file-changed",
|
||||
crate::types::FileEvent {
|
||||
event_type: "modify".to_string(),
|
||||
path: vault.clone(),
|
||||
},
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -50,10 +50,48 @@ pub struct NoteContent {
|
||||
pub raw: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct VaultConfig {
|
||||
pub path: String,
|
||||
pub name: String,
|
||||
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_open: bool,
|
||||
#[serde(default)]
|
||||
pub sync_on_change: bool,
|
||||
#[serde(default)]
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomThemeColors {
|
||||
pub bg_primary: String,
|
||||
pub bg_secondary: String,
|
||||
pub bg_tertiary: String,
|
||||
pub bg_hover: String,
|
||||
pub bg_active: String,
|
||||
pub bg_editor: String,
|
||||
pub text_primary: String,
|
||||
pub text_secondary: String,
|
||||
pub border_color: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CustomTheme {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub is_dark: bool,
|
||||
pub colors: CustomThemeColors,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -70,9 +108,19 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub line_height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub ui_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub content_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub compact_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
#[serde(default = "default_daily_title_format")]
|
||||
pub daily_title_format: String,
|
||||
#[serde(default)]
|
||||
pub gpu_acceleration: bool,
|
||||
#[serde(default)]
|
||||
@@ -87,6 +135,18 @@ pub struct AppConfig {
|
||||
pub hide_title_in_body: bool,
|
||||
#[serde(default)]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_link_arrows: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_all_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_quick_access: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tasks: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_daily_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_trash: bool,
|
||||
#[serde(default)]
|
||||
pub backup_enabled: bool,
|
||||
#[serde(default = "default_backup_frequency")]
|
||||
@@ -109,6 +169,12 @@ pub struct AppConfig {
|
||||
pub openai_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ollama_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_api_key: Option<String>,
|
||||
#[serde(default = "default_ai_model")]
|
||||
pub ai_model: String,
|
||||
#[serde(default)]
|
||||
@@ -121,12 +187,37 @@ pub struct AppConfig {
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_open: bool,
|
||||
#[serde(default)]
|
||||
pub sync_on_change: bool,
|
||||
#[serde(default)]
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
#[serde(default)]
|
||||
pub custom_themes: Vec<CustomTheme>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_outline_width() -> f64 {
|
||||
220.0
|
||||
}
|
||||
|
||||
fn default_pdf_height() -> u32 {
|
||||
600
|
||||
}
|
||||
@@ -143,6 +234,14 @@ fn default_title_mode() -> String {
|
||||
"input".to_string()
|
||||
}
|
||||
|
||||
fn default_week_start() -> String {
|
||||
"monday".to_string()
|
||||
}
|
||||
|
||||
fn default_daily_title_format() -> String {
|
||||
"localized".to_string()
|
||||
}
|
||||
|
||||
fn default_max_versions() -> u32 {
|
||||
20
|
||||
}
|
||||
@@ -161,8 +260,13 @@ impl Default for AppConfig {
|
||||
font_size: None,
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
gpu_acceleration: true,
|
||||
autostart: false,
|
||||
pdf_preview: false,
|
||||
@@ -170,6 +274,12 @@ impl Default for AppConfig {
|
||||
title_mode: "input".to_string(),
|
||||
hide_title_in_body: false,
|
||||
show_line_numbers: false,
|
||||
show_link_arrows: true,
|
||||
show_all_notes: true,
|
||||
show_quick_access: true,
|
||||
show_tasks: true,
|
||||
show_daily_notes: true,
|
||||
show_trash: true,
|
||||
backup_enabled: false,
|
||||
backup_frequency: "24h".to_string(),
|
||||
backup_max_count: 10,
|
||||
@@ -181,12 +291,25 @@ impl Default for AppConfig {
|
||||
ai_api_key: None,
|
||||
openai_api_key: None,
|
||||
ollama_base_url: None,
|
||||
ollama_api_key: None,
|
||||
openai_compatible_base_url: None,
|
||||
openai_compatible_api_key: None,
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
webdav_username: None,
|
||||
webdav_password: None,
|
||||
sync_on_open: false,
|
||||
sync_on_change: false,
|
||||
sync_interval_minutes: 0,
|
||||
last_sync_time: None,
|
||||
custom_themes: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -196,13 +319,35 @@ pub struct VaultState {
|
||||
pub last_open_note: Option<String>,
|
||||
pub sidebar_width: f64,
|
||||
pub notelist_width: f64,
|
||||
#[serde(default = "default_outline_width")]
|
||||
pub outline_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub notelist_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub collapsed_notebooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notebook_sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub note_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_view_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_notebook: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tasks_layout: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub tasks_hide_completed: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_only_flagged: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_sort: String,
|
||||
}
|
||||
|
||||
impl Default for VaultState {
|
||||
@@ -211,10 +356,21 @@ impl Default for VaultState {
|
||||
last_open_note: None,
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
outline_width: default_outline_width(),
|
||||
sidebar_collapsed: false,
|
||||
notelist_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
notebook_sort_mode: String::new(),
|
||||
notebook_order: std::collections::HashMap::new(),
|
||||
note_order: std::collections::HashMap::new(),
|
||||
sort_mode: String::new(),
|
||||
last_view_mode: String::new(),
|
||||
last_notebook: None,
|
||||
last_tag: None,
|
||||
tasks_layout: String::new(),
|
||||
tasks_hide_completed: true,
|
||||
tasks_only_flagged: false,
|
||||
tasks_sort: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -293,3 +449,15 @@ pub struct GraphEdge {
|
||||
pub target: usize,
|
||||
pub bidirectional: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskItem {
|
||||
pub note_path: String,
|
||||
pub note_title: String,
|
||||
pub line: usize,
|
||||
pub raw_line: String,
|
||||
pub text: String,
|
||||
pub completed: bool,
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
@@ -52,11 +52,10 @@ pub fn scan_notebooks(vault_path: &str) -> Result<Vec<NotebookEntry>, String> {
|
||||
}
|
||||
|
||||
fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
let mut entries = Vec::new();
|
||||
let root = Path::new(vault_root);
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return entries;
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
||||
@@ -76,29 +75,29 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
});
|
||||
|
||||
// Store note_count for the parent directory (used when this is the top-level call)
|
||||
// Each directory pushes its own entry with its own note_count
|
||||
for dir_entry in dirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
// Scan sibling notebooks in parallel: each subtree is independent, so
|
||||
// overlapping the per-directory read_dir calls hides FUSE latency on Android.
|
||||
// par_iter().collect() preserves the (already-sorted) order.
|
||||
let paths: Vec<PathBuf> = dirs.iter().map(|e| e.path()).collect();
|
||||
paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||
NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
||||
@@ -130,31 +129,31 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
});
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for dir_entry in subdirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
let paths: Vec<PathBuf> = subdirs.iter().map(|e| e.path()).collect();
|
||||
let entries: Vec<NotebookEntry> = paths
|
||||
.par_iter()
|
||||
.map(|path| {
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (children, child_note_count) = scan_dir_with_count(path, vault_root);
|
||||
NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
(entries, note_count)
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
pub(crate) fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| {
|
||||
@@ -194,56 +193,45 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
|
||||
return Err("Path does not exist".to_string());
|
||||
}
|
||||
|
||||
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
|
||||
#[cfg(target_os = "android")]
|
||||
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let mut notes = Vec::new();
|
||||
// Log what read_dir sees
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
||||
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
|
||||
for (i, entry) in items.iter().enumerate().take(5) {
|
||||
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
|
||||
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
|
||||
}
|
||||
} else {
|
||||
log::info!("scan_notes: read_dir FAILED for {:?}", root);
|
||||
}
|
||||
|
||||
if notebook_path.is_some() {
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
|
||||
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Collect candidate .md paths first, then read their metadata in parallel.
|
||||
// Overlapping the per-file reads hides FUSE latency on Android (matches the
|
||||
// desktop path below). The previous version also did an extra read_dir purely
|
||||
// for debug logging, which doubled the directory I/O on the sandboxed FS.
|
||||
let paths: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
match fs::read_dir(root) {
|
||||
Ok(rd) => rd
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect(),
|
||||
Err(_) => Vec::new(),
|
||||
}
|
||||
} else {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
for entry in WalkDir::new(root)
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
// filter_entry skips descending into hidden dirs (unlike filter)
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_type().is_file()
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
{
|
||||
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("scan_notes: Android scan found {} notes", notes.len());
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut notes: Vec<NoteEntry> = paths
|
||||
.par_iter()
|
||||
.filter_map(|path| read_note_entry_metadata_only(path, vault_root).ok())
|
||||
.collect();
|
||||
|
||||
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
return Ok(notes);
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
fs::read_dir(root)
|
||||
@@ -279,7 +267,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
|
||||
|
||||
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
|
||||
/// uses filesystem timestamps for dates. No preview text.
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
// Read first 2KB - enough for frontmatter with tags, title, pinned
|
||||
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
@@ -386,7 +374,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
|
||||
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
|
||||
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
|
||||
// resetting the create date of imported/frontmatter-less notes. (issue #139)
|
||||
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
|
||||
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
|
||||
if let Ok(fs_meta) = fs::metadata(p) {
|
||||
if !has_modified {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
}
|
||||
}
|
||||
if !has_created {
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NoteContent {
|
||||
path: path.to_string(),
|
||||
@@ -520,15 +527,27 @@ fn get_system_locale() -> Locale {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
|
||||
pub fn create_daily_note(
|
||||
vault_path: &str,
|
||||
date: Option<&str>,
|
||||
format: &str,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let target_date = match date {
|
||||
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
|
||||
.map_err(|e| format!("Invalid date: {}", e))?,
|
||||
None => Local::now().date_naive(),
|
||||
};
|
||||
let date_str = target_date.format("%Y-%m-%d").to_string();
|
||||
let locale = get_system_locale();
|
||||
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
|
||||
let title = match format {
|
||||
"iso" => target_date.format("%Y-%m-%d").to_string(),
|
||||
"long" => target_date.format("%B %-d, %Y").to_string(),
|
||||
"us" => target_date.format("%m/%d/%Y").to_string(),
|
||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||
_ => {
|
||||
let locale = get_system_locale();
|
||||
target_date.format_localized("%B %d, %Y", locale).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let dir = Path::new(vault_path).join("Daily");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
@@ -708,17 +727,20 @@ fn update_wikilinks_after_rename(
|
||||
new_title: &str,
|
||||
) {
|
||||
let vault = Path::new(vault_path);
|
||||
let vault_prefix = format!("{}/", vault_path);
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
|
||||
// Compute vault-relative path refs (without .md) for path-based wikilink refs
|
||||
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
|
||||
.unwrap_or(old_path)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(old_path);
|
||||
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
|
||||
.unwrap_or(new_path)
|
||||
.strip_suffix(".md")
|
||||
.unwrap_or(new_path);
|
||||
// Vault-relative path ref (without .md), normalized to forward slashes so it
|
||||
// matches [[folder/note]] wikilinks on Windows (OS paths use backslashes there).
|
||||
let rel_ref = |p: &str| -> String {
|
||||
let rel = Path::new(p)
|
||||
.strip_prefix(vault)
|
||||
.map(|r| r.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
.replace('\\', "/");
|
||||
rel.strip_suffix(".md").unwrap_or(&rel).to_string()
|
||||
};
|
||||
let old_rel_ref = rel_ref(old_path);
|
||||
let new_rel_ref = rel_ref(new_path);
|
||||
|
||||
// Check if another note with the same old_title exists in the vault.
|
||||
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
|
||||
@@ -726,6 +748,7 @@ fn update_wikilinks_after_rename(
|
||||
// vs. the other note with the same title.
|
||||
let title_is_unique = !WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| {
|
||||
let p = e.path();
|
||||
@@ -735,10 +758,6 @@ fn update_wikilinks_after_rename(
|
||||
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
return false;
|
||||
}
|
||||
let p_str = p.to_string_lossy();
|
||||
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
|
||||
return false;
|
||||
}
|
||||
// Check if this note's filename (without .md) matches the old title
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
@@ -748,12 +767,12 @@ fn update_wikilinks_after_rename(
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
||||
// Skip the renamed note itself
|
||||
if *path_str == *new_path { continue; }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"productName": "HelixNotes",
|
||||
"version": "1.2.9",
|
||||
"version": "1.3.3",
|
||||
"identifier": "com.helixnotes.app",
|
||||
"build": {
|
||||
"frontendDist": "../build",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||
"bundle": {
|
||||
"iOS": {
|
||||
"minimumSystemVersion": "14.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
"windows": [
|
||||
{
|
||||
"label": "main",
|
||||
"dragDropEnabled": false,
|
||||
"decorations": true,
|
||||
"titleBarStyle": "Overlay",
|
||||
"hiddenTitle": true,
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "HelixNotes",
|
||||
"label": "main",
|
||||
"width": 1200,
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 500,
|
||||
"resizable": true,
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"dragDropEnabled": false
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
NoteContent,
|
||||
NoteEntry,
|
||||
NoteMeta,
|
||||
@@ -13,12 +14,17 @@ import type {
|
||||
ImportResult,
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
} from "./types";
|
||||
|
||||
export async function openVault(path: string): Promise<void> {
|
||||
return invoke("open_vault", { path });
|
||||
}
|
||||
|
||||
export async function removeVault(path: string): Promise<void> {
|
||||
return invoke("remove_vault", { path });
|
||||
}
|
||||
|
||||
export async function getAppConfig(): Promise<AppConfig> {
|
||||
return invoke("get_app_config");
|
||||
}
|
||||
@@ -31,6 +37,22 @@ export async function setAccentColor(color: string): Promise<void> {
|
||||
return invoke("set_accent_color", { color });
|
||||
}
|
||||
|
||||
export async function saveCustomTheme(theme: CustomTheme): Promise<void> {
|
||||
return invoke("save_custom_theme", { theme });
|
||||
}
|
||||
|
||||
export async function deleteCustomTheme(id: string): Promise<void> {
|
||||
return invoke("delete_custom_theme", { id });
|
||||
}
|
||||
|
||||
export async function exportCustomTheme(id: string, path: string): Promise<void> {
|
||||
return invoke("export_custom_theme", { id, path });
|
||||
}
|
||||
|
||||
export async function importCustomThemes(path: string): Promise<CustomTheme[]> {
|
||||
return invoke("import_custom_themes", { path });
|
||||
}
|
||||
|
||||
export async function setFontSize(size: number): Promise<void> {
|
||||
return invoke("set_font_size", { size });
|
||||
}
|
||||
@@ -43,6 +65,14 @@ export async function setLineHeight(height: number): Promise<void> {
|
||||
return invoke("set_line_height", { height });
|
||||
}
|
||||
|
||||
export async function setUiScale(scale: number): Promise<void> {
|
||||
return invoke("set_ui_scale", { scale });
|
||||
}
|
||||
|
||||
export async function setContentWidth(width: number | null): Promise<void> {
|
||||
return invoke("set_content_width", { width });
|
||||
}
|
||||
|
||||
export async function getNotebooks(): Promise<NotebookEntry[]> {
|
||||
return invoke("get_notebooks");
|
||||
}
|
||||
@@ -210,6 +240,8 @@ export async function setNotebookIcon(
|
||||
export async function setGeneralSettings(
|
||||
compactNotes: boolean,
|
||||
timeFormat: string,
|
||||
weekStart: string,
|
||||
dailyTitleFormat: string,
|
||||
gpuAcceleration: boolean,
|
||||
autostart: boolean,
|
||||
pdfPreview: boolean,
|
||||
@@ -217,14 +249,24 @@ export async function setGeneralSettings(
|
||||
titleMode: string,
|
||||
hideTitleInBody: boolean,
|
||||
showLineNumbers: boolean,
|
||||
showLinkArrows: boolean,
|
||||
defaultViewMode: boolean,
|
||||
showTrayIcon: boolean,
|
||||
closeToTray: boolean,
|
||||
enableWikiLinks: boolean,
|
||||
showNoteDates: boolean,
|
||||
restoreLastSession: boolean,
|
||||
showAllNotes: boolean,
|
||||
showQuickAccess: boolean,
|
||||
showTasks: boolean,
|
||||
showDailyNotes: boolean,
|
||||
showTrash: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_general_settings", {
|
||||
compactNotes,
|
||||
timeFormat,
|
||||
weekStart,
|
||||
dailyTitleFormat,
|
||||
gpuAcceleration,
|
||||
autostart,
|
||||
pdfPreview,
|
||||
@@ -232,10 +274,18 @@ export async function setGeneralSettings(
|
||||
titleMode,
|
||||
hideTitleInBody,
|
||||
showLineNumbers,
|
||||
showLinkArrows,
|
||||
defaultViewMode,
|
||||
showTrayIcon,
|
||||
closeToTray,
|
||||
enableWikiLinks,
|
||||
showNoteDates,
|
||||
restoreLastSession,
|
||||
showAllNotes,
|
||||
showQuickAccess,
|
||||
showTasks,
|
||||
showDailyNotes,
|
||||
showTrash,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -259,6 +309,19 @@ export async function getVaultStats(): Promise<VaultStats> {
|
||||
return invoke("get_vault_stats");
|
||||
}
|
||||
|
||||
export interface OrphanAttachment {
|
||||
name: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
|
||||
return invoke("find_orphaned_attachments");
|
||||
}
|
||||
|
||||
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
|
||||
return invoke("trash_orphaned_attachments", { names });
|
||||
}
|
||||
|
||||
export async function importObsidian(): Promise<void> {
|
||||
return invoke("import_obsidian");
|
||||
}
|
||||
@@ -323,6 +386,61 @@ export async function setBackupSettings(
|
||||
});
|
||||
}
|
||||
|
||||
// ── Tasks ──
|
||||
|
||||
export async function getTasks(): Promise<TaskItem[]> {
|
||||
return invoke("get_tasks");
|
||||
}
|
||||
|
||||
export async function setTaskDone(
|
||||
notePath: string,
|
||||
line: number,
|
||||
rawLine: string,
|
||||
done: boolean,
|
||||
): Promise<void> {
|
||||
return invoke("set_task_done", { notePath, line, rawLine, done });
|
||||
}
|
||||
|
||||
export async function setTaskPriority(
|
||||
notePath: string,
|
||||
line: number,
|
||||
rawLine: string,
|
||||
priority: string | null,
|
||||
): Promise<void> {
|
||||
return invoke("set_task_priority", { notePath, line, rawLine, priority });
|
||||
}
|
||||
|
||||
export async function setTaskDue(
|
||||
notePath: string,
|
||||
line: number,
|
||||
rawLine: string,
|
||||
due: string | null,
|
||||
): Promise<void> {
|
||||
return invoke("set_task_due", { notePath, line, rawLine, due });
|
||||
}
|
||||
|
||||
// ── Sync (WebDAV) ──
|
||||
|
||||
export async function setSyncSettings(
|
||||
provider: string | null,
|
||||
url: string | null,
|
||||
username: string | null,
|
||||
password: string | null,
|
||||
syncOnOpen: boolean,
|
||||
syncOnChange: boolean,
|
||||
syncIntervalMinutes: number,
|
||||
): Promise<void> {
|
||||
return invoke("set_sync_settings", { provider, url, username, password, syncOnOpen, syncOnChange, syncIntervalMinutes });
|
||||
}
|
||||
|
||||
export async function testSyncConnection(): Promise<void> {
|
||||
return invoke("test_sync_connection");
|
||||
}
|
||||
|
||||
export async function syncNow(): Promise<void> {
|
||||
return invoke("sync_now");
|
||||
}
|
||||
|
||||
// ── Version History ──
|
||||
|
||||
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
|
||||
@@ -351,8 +469,14 @@ export async function setAiSettings(
|
||||
model: string,
|
||||
writingStyle: string | null,
|
||||
baseUrl: string | null = null,
|
||||
ollamaApiKey: string | null = null,
|
||||
openaiCompatibleBaseUrl: string | null = null,
|
||||
openaiCompatibleApiKey: string | null = null,
|
||||
): Promise<void> {
|
||||
return invoke("set_ai_settings", { provider, apiKey, model, writingStyle, baseUrl });
|
||||
return invoke("set_ai_settings", {
|
||||
provider, apiKey, model, writingStyle, baseUrl,
|
||||
ollamaApiKey, openaiCompatibleBaseUrl, openaiCompatibleApiKey,
|
||||
});
|
||||
}
|
||||
|
||||
export async function testAiConnection(): Promise<void> {
|
||||
@@ -372,6 +496,10 @@ export async function getInstallType(): Promise<string> {
|
||||
return invoke("get_install_type");
|
||||
}
|
||||
|
||||
export async function isMobilePlatform(): Promise<boolean> {
|
||||
return invoke("is_mobile_platform");
|
||||
}
|
||||
|
||||
export async function getPendingOpenFile(): Promise<string | null> {
|
||||
return invoke("get_pending_open_file");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { onMount, onDestroy, tick } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import Sidebar from './Sidebar.svelte';
|
||||
import NoteList from './NoteList.svelte';
|
||||
@@ -14,7 +14,9 @@
|
||||
import {
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
outlineWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
showSearch,
|
||||
showCommandPalette,
|
||||
@@ -33,25 +35,47 @@
|
||||
quickAccessPaths,
|
||||
tags,
|
||||
notes,
|
||||
notebooks,
|
||||
rootNoteCount,
|
||||
viewMode,
|
||||
sortMode,
|
||||
groupNotesByDate,
|
||||
tasksLayout,
|
||||
tasksHideCompleted,
|
||||
tasksOnlyFlagged,
|
||||
tasksSort,
|
||||
activeTag,
|
||||
updateAvailable as globalUpdateAvailable,
|
||||
settingsTab,
|
||||
navHistory,
|
||||
viewerNote,
|
||||
notebookSortMode,
|
||||
notebookOrder
|
||||
notebookOrder,
|
||||
noteOrder,
|
||||
syncState,
|
||||
platformIsMobile,
|
||||
activeVaultConfig,
|
||||
} from '$lib/stores/app';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
|
||||
const isMobile = $derived($platformIsMobile);
|
||||
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
|
||||
import { darkThemes, isAndroid } from '$lib/platform';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { get } from 'svelte/store';
|
||||
import type { VaultState, FileEvent } from '$lib/types';
|
||||
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
|
||||
|
||||
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
|
||||
for (const nb of list) {
|
||||
if (nb.relative_path === relPath) return nb;
|
||||
const found = findNotebookByPath(nb.children, relPath);
|
||||
if (found) return found;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
let sidebar: Sidebar;
|
||||
let noteList: NoteList;
|
||||
@@ -63,6 +87,11 @@
|
||||
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
|
||||
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
|
||||
let backupInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let syncInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let unlistenSync: Array<() => void> = [];
|
||||
let unsubDirty: (() => void) | null = null;
|
||||
let onChangeSyncTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let prevDirty = false;
|
||||
|
||||
|
||||
|
||||
@@ -92,6 +121,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
function syncConfigured(): boolean {
|
||||
return activeVaultConfig(get(appConfig))?.sync_provider === 'webdav';
|
||||
}
|
||||
|
||||
// Auto-sync on a timer (only when configured and an interval is set).
|
||||
async function checkScheduledSync() {
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (vc?.sync_provider !== 'webdav') return;
|
||||
const mins = vc.sync_interval_minutes ?? 0;
|
||||
if (!mins || get(syncState).running) return;
|
||||
const last = vc.last_sync_time ? new Date(vc.last_sync_time).getTime() : 0;
|
||||
if (Date.now() - last >= mins * 60 * 1000) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
export async function triggerSyncNow() {
|
||||
if (!syncConfigured() || get(syncState).running) return;
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Track note navigation in history stack
|
||||
$effect(() => {
|
||||
const path = $activeNotePath;
|
||||
@@ -154,10 +204,22 @@
|
||||
last_open_note: $activeNotePath,
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
outline_width: $outlineWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
notebook_sort_mode: $notebookSortMode,
|
||||
notebook_order: $notebookOrder
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
sort_mode: $sortMode,
|
||||
group_notes_by_date: $groupNotesByDate,
|
||||
last_view_mode: $viewMode,
|
||||
last_notebook: $activeNotebook?.relative_path ?? null,
|
||||
last_tag: $activeTag,
|
||||
tasks_layout: $tasksLayout,
|
||||
tasks_hide_completed: $tasksHideCompleted,
|
||||
tasks_only_flagged: $tasksOnlyFlagged,
|
||||
tasks_sort: $tasksSort
|
||||
};
|
||||
try {
|
||||
await saveVaultState(state);
|
||||
@@ -174,15 +236,40 @@
|
||||
persistState();
|
||||
}
|
||||
|
||||
function revealNoteList() {
|
||||
$notelistCollapsed = false;
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
|
||||
function toggleNoteList() {
|
||||
const nextCollapsed = !$notelistCollapsed;
|
||||
$notelistCollapsed = nextCollapsed;
|
||||
if (!nextCollapsed) {
|
||||
tick().then(() => noteList?.refresh());
|
||||
}
|
||||
}
|
||||
|
||||
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
|
||||
let taskNoteOpened = $state(false);
|
||||
|
||||
function handleNoteSelected(path: string, content: string) {
|
||||
// Selecting a real vault note exits viewer mode
|
||||
$viewerNote = null;
|
||||
taskNoteOpened = true;
|
||||
editor?.loadNote(path, content);
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
noteList?.refresh();
|
||||
taskNoteOpened = false;
|
||||
// Picking a notebook/tag/Tasks/etc. in the sidebar is a request to browse that view's
|
||||
// notes - so if the notes list was hidden, bring it back rather than navigating blindly
|
||||
// into a panel the user can't see. revealNoteList() re-mounts the list and refreshes it.
|
||||
if ($notelistCollapsed) {
|
||||
revealNoteList();
|
||||
} else {
|
||||
noteList?.refresh();
|
||||
}
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
}
|
||||
|
||||
@@ -192,6 +279,63 @@
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
// Toggle a task done from the Tasks view. If the task's note is open in the editor,
|
||||
// flush first then reload after the file edit (so we never clobber unsaved edits).
|
||||
async function toggleTask(task: TaskItem) {
|
||||
const done = !task.completed;
|
||||
const isActive = task.note_path === $activeNotePath;
|
||||
if (isActive) await editor?.forceSave();
|
||||
try {
|
||||
await setTaskDone(task.note_path, task.line, task.raw_line, done);
|
||||
} catch (e) {
|
||||
console.error('Failed to toggle task:', e);
|
||||
}
|
||||
if (isActive) {
|
||||
try {
|
||||
const content = await readNote(task.note_path);
|
||||
$activeNote = content;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(task.note_path, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTaskPriority(task: TaskItem, priority: string | null) {
|
||||
const isActive = task.note_path === $activeNotePath;
|
||||
if (isActive) await editor?.forceSave();
|
||||
try {
|
||||
await setTaskPriority(task.note_path, task.line, task.raw_line, priority);
|
||||
} catch (e) {
|
||||
console.error('Failed to set task priority:', e);
|
||||
}
|
||||
if (isActive) {
|
||||
try {
|
||||
const content = await readNote(task.note_path);
|
||||
$activeNote = content;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(task.note_path, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function changeTaskDue(task: TaskItem, due: string | null) {
|
||||
const isActive = task.note_path === $activeNotePath;
|
||||
if (isActive) await editor?.forceSave();
|
||||
try {
|
||||
await setTaskDue(task.note_path, task.line, task.raw_line, due);
|
||||
} catch (e) {
|
||||
console.error('Failed to set task due date:', e);
|
||||
}
|
||||
if (isActive) {
|
||||
try {
|
||||
const content = await readNote(task.note_path);
|
||||
$activeNote = content;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(task.note_path, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDailyNote() {
|
||||
try {
|
||||
const entry = await createDailyNote();
|
||||
@@ -216,7 +360,7 @@
|
||||
let historyDepth = 0;
|
||||
let navFromPopstate = false;
|
||||
|
||||
if (isMobile) {
|
||||
if (get(platformIsMobile)) {
|
||||
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
|
||||
|
||||
$effect(() => {
|
||||
@@ -246,6 +390,16 @@
|
||||
navFromPopstate = false;
|
||||
return;
|
||||
}
|
||||
// If a modal is open, close it instead of navigating
|
||||
if ($showSettings || $showInfo || $showSearch || $showCommandPalette) {
|
||||
$showSettings = false;
|
||||
$showInfo = false;
|
||||
$showSearch = false;
|
||||
$showCommandPalette = false;
|
||||
// Re-push the current state so the next back still works
|
||||
history.pushState({ mobileView: $mobileView, depth: historyDepth }, '');
|
||||
return;
|
||||
}
|
||||
const state = e.state;
|
||||
const targetDepth = state?.depth ?? 0;
|
||||
historyDepth = targetDepth;
|
||||
@@ -269,88 +423,83 @@
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
const code = e.code;
|
||||
if (e.altKey && e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
}
|
||||
if (e.altKey && e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
navigateHistory(1);
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
createAndFocusNote();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyN') {
|
||||
e.preventDefault();
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
$showSearch = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyF') {
|
||||
e.preventDefault();
|
||||
if ($activeNotePath) {
|
||||
editor?.openNoteSearch();
|
||||
} else {
|
||||
$showSearch = true;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyP') {
|
||||
e.preventDefault();
|
||||
$showCommandPalette = true;
|
||||
return;
|
||||
}
|
||||
if (mod && !e.shiftKey && code === 'KeyS') {
|
||||
e.preventDefault();
|
||||
editor?.forceSave();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mod+K (insert link) is an editor formatting action and is not user-customizable.
|
||||
if (mod && code === 'KeyK' && !e.shiftKey && $activeNotePath && !$sourceMode) {
|
||||
e.preventDefault();
|
||||
editor?.addLinkFromToolbar();
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyM') {
|
||||
|
||||
// Ctrl/Cmd+Shift+Delete: move the open note to the trash.
|
||||
if (mod && e.shiftKey && code === 'Delete' && $activeNotePath) {
|
||||
e.preventDefault();
|
||||
$sourceMode = !$sourceMode;
|
||||
noteList?.trashActiveNote();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyW') {
|
||||
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
if ($activeNotePath && $activeNote) {
|
||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||
switch (action) {
|
||||
case 'nav-back':
|
||||
navigateHistory(-1);
|
||||
return;
|
||||
case 'nav-forward':
|
||||
navigateHistory(1);
|
||||
return;
|
||||
case 'new-note':
|
||||
createAndFocusNote();
|
||||
return;
|
||||
case 'toggle-theme': {
|
||||
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const next = isDark ? 'light' : 'dark';
|
||||
$theme = next;
|
||||
setTheme(next);
|
||||
return;
|
||||
}
|
||||
case 'search-vault':
|
||||
$showSearch = true;
|
||||
return;
|
||||
case 'find-in-note':
|
||||
if ($activeNotePath) {
|
||||
editor?.openNoteSearch();
|
||||
} else {
|
||||
$showSearch = true;
|
||||
}
|
||||
return;
|
||||
case 'quick-open':
|
||||
$showCommandPalette = true;
|
||||
return;
|
||||
case 'save':
|
||||
editor?.forceSave();
|
||||
return;
|
||||
case 'toggle-source':
|
||||
$sourceMode = !$sourceMode;
|
||||
return;
|
||||
case 'open-new-window':
|
||||
if ($activeNotePath && $activeNote) {
|
||||
openNoteWindow($activeNotePath, $activeNote.meta.title);
|
||||
}
|
||||
return;
|
||||
case 'toggle-sidebar':
|
||||
$sidebarCollapsed = !$sidebarCollapsed;
|
||||
return;
|
||||
case 'toggle-notelist':
|
||||
toggleNoteList();
|
||||
return;
|
||||
case 'toggle-focus':
|
||||
$focusMode = !$focusMode;
|
||||
return;
|
||||
case 'toggle-readonly':
|
||||
if ($viewerNote) return; // viewer mode is always read-only
|
||||
$readOnly = !$readOnly;
|
||||
return;
|
||||
case 'fullscreen':
|
||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (mod && e.key === '\\') {
|
||||
e.preventDefault();
|
||||
$sidebarCollapsed = !$sidebarCollapsed;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyE') {
|
||||
e.preventDefault();
|
||||
$focusMode = !$focusMode;
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && code === 'KeyR') {
|
||||
e.preventDefault();
|
||||
if ($viewerNote) return; // viewer mode is always read-only
|
||||
$readOnly = !$readOnly;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'F11') {
|
||||
e.preventDefault();
|
||||
appWindow.isFullscreen().then(fs => appWindow.setFullscreen(!fs));
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'Escape') {
|
||||
if ($showSettings) $showSettings = false;
|
||||
else if ($showInfo) $showInfo = false;
|
||||
@@ -361,9 +510,14 @@
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
@@ -382,23 +536,67 @@
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sidebarCollapsed;
|
||||
$notelistCollapsed;
|
||||
$outlineWidth;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$notebookSortMode;
|
||||
$notebookOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$noteOrder;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$sortMode;
|
||||
$groupNotesByDate;
|
||||
$tasksLayout;
|
||||
$tasksHideCompleted;
|
||||
$tasksOnlyFlagged;
|
||||
$tasksSort;
|
||||
persistState();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
$viewMode;
|
||||
$activeNotebook;
|
||||
$activeTag;
|
||||
persistState();
|
||||
});
|
||||
|
||||
onMount(async () => {
|
||||
let lastNotePath: string | null = null;
|
||||
let lastViewMode = '';
|
||||
let lastNotebook: string | null = null;
|
||||
let lastTag: string | null = null;
|
||||
try {
|
||||
const state = await loadVaultState();
|
||||
$sidebarWidth = state.sidebar_width;
|
||||
$notelistWidth = state.notelist_width;
|
||||
if (typeof state.outline_width === 'number') $outlineWidth = state.outline_width;
|
||||
$sidebarCollapsed = state.sidebar_collapsed;
|
||||
$notelistCollapsed = state.notelist_collapsed ?? false;
|
||||
$collapsedNotebooks = state.collapsed_notebooks ?? [];
|
||||
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
|
||||
$notebookOrder = state.notebook_order ?? {};
|
||||
$noteOrder = state.note_order ?? {};
|
||||
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified' || state.sort_mode === 'custom') $sortMode = state.sort_mode;
|
||||
if (typeof state.group_notes_by_date === 'boolean') $groupNotesByDate = state.group_notes_by_date;
|
||||
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
|
||||
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
|
||||
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
|
||||
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
|
||||
lastNotePath = state.last_open_note ?? null;
|
||||
lastViewMode = state.last_view_mode ?? '';
|
||||
lastNotebook = state.last_notebook ?? null;
|
||||
lastTag = state.last_tag ?? null;
|
||||
} catch (_) {}
|
||||
|
||||
// On mobile, prefetch last-opened note so first tap is instant
|
||||
@@ -416,6 +614,34 @@
|
||||
// Run sidebar and note list refresh in parallel
|
||||
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
|
||||
|
||||
// Restore the last session (view + open note) if enabled.
|
||||
if ($appConfig?.restore_last_session) {
|
||||
const vault = $appConfig?.active_vault;
|
||||
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
|
||||
$viewMode = 'notebook';
|
||||
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
|
||||
$activeTag = null;
|
||||
await noteList?.refresh();
|
||||
} else if (lastViewMode === 'notebook' && lastNotebook) {
|
||||
const nb = findNotebookByPath($notebooks, lastNotebook);
|
||||
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
|
||||
} else if (lastViewMode === 'tag' && lastTag) {
|
||||
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
|
||||
} else if (lastViewMode === 'quickaccess') {
|
||||
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
|
||||
}
|
||||
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
|
||||
if (!isMobile && lastNotePath) {
|
||||
try {
|
||||
const content = await readNote(lastNotePath);
|
||||
$activeNote = content;
|
||||
$activeNotePath = lastNotePath;
|
||||
$editorDirty = false;
|
||||
editor?.loadNote(lastNotePath, content.content);
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
|
||||
if (isMobile) {
|
||||
const tagMap = new Map<string, number>();
|
||||
@@ -480,12 +706,45 @@
|
||||
// Scheduled backup: check on startup and every 5 minutes
|
||||
checkScheduledBackup();
|
||||
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
|
||||
|
||||
// ── WebDAV sync: global status + auto-sync triggers ──
|
||||
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
|
||||
unlistenSync.push(await listen('sync-done', (event: any) => {
|
||||
syncState.set({ running: false, error: null });
|
||||
const cur = get(appConfig);
|
||||
const ts = event.payload?.last_sync_time;
|
||||
if (cur && ts) appConfig.set({ ...cur, vaults: cur.vaults.map((v) => v.path === cur.active_vault ? { ...v, last_sync_time: ts } : v) });
|
||||
}));
|
||||
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
|
||||
|
||||
// Sync when the vault opens (if enabled)
|
||||
if (syncConfigured() && activeVaultConfig(get(appConfig))?.sync_on_open) {
|
||||
try { await syncNow(); } catch (_) {}
|
||||
}
|
||||
|
||||
// Auto-sync interval: check on startup and every minute
|
||||
checkScheduledSync();
|
||||
syncInterval = setInterval(checkScheduledSync, 60 * 1000);
|
||||
|
||||
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
|
||||
unsubDirty = editorDirty.subscribe((d) => {
|
||||
const vc = activeVaultConfig(get(appConfig));
|
||||
if (prevDirty && !d && vc?.sync_provider === 'webdav' && vc?.sync_on_change) {
|
||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
|
||||
}
|
||||
prevDirty = d;
|
||||
});
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
unlistenFileChange?.();
|
||||
unlistenOpenFile?.();
|
||||
if (backupInterval) clearInterval(backupInterval);
|
||||
if (syncInterval) clearInterval(syncInterval);
|
||||
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
|
||||
unsubDirty?.();
|
||||
unlistenSync.forEach((u) => u());
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -518,7 +777,7 @@
|
||||
{#if $mobileView === 'sidebar'}
|
||||
HelixNotes
|
||||
{:else}
|
||||
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
|
||||
{/if}
|
||||
</span>
|
||||
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
|
||||
@@ -579,13 +838,20 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
|
||||
{#if $appConfig?.ai_provider}
|
||||
<button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
||||
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
|
||||
@@ -606,6 +872,13 @@
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
</button>
|
||||
<button class="mobile-header-btn" onclick={() => ($showSettings = true)} title="Settings">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
@@ -622,12 +895,7 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
|
||||
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
|
||||
<Editor bind:this={editor} />
|
||||
@@ -686,22 +954,43 @@
|
||||
<ResizeHandle onResize={handleSidebarResize} />
|
||||
{/if}
|
||||
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
|
||||
</div>
|
||||
{#if !$notelistCollapsed}
|
||||
<div class="notelist-panel" style="width: {$notelistWidth}px">
|
||||
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
|
||||
</div>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
{:else}
|
||||
<div class="notelist-restore-panel">
|
||||
<button class="notelist-restore-btn" onclick={revealNoteList} title={`Show notes list (${isMac ? '⌘' : 'Ctrl'}+Shift+\\)`} aria-label="Show notes list">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="20" y2="6" />
|
||||
<line x1="8" y1="12" x2="20" y2="12" />
|
||||
<line x1="8" y1="18" x2="20" y2="18" />
|
||||
<polyline points="5 8 3 12 5 16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="editor-panel">
|
||||
<Editor bind:this={editor} />
|
||||
{#if $viewMode === 'tasks' && !taskNoteOpened}
|
||||
<div class="tasks-editor-placeholder">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
|
||||
</svg>
|
||||
<p>Select a task to open its note</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<SearchPanel />
|
||||
<CommandPalette />
|
||||
<CommandPalette onNavigate={handleViewChanged} />
|
||||
<SettingsPanel />
|
||||
<InfoPanel />
|
||||
|
||||
@@ -732,11 +1021,56 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notelist-restore-panel {
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
height: 100%;
|
||||
padding-top: 8px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notelist-restore-btn {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notelist-restore-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.editor-panel {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 300px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tasks-editor-placeholder {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-tertiary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.focus-topbar {
|
||||
@@ -904,27 +1238,4 @@
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.mobile-fab {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
right: 20px;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mobile-fab:active {
|
||||
transform: scale(0.93);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,19 @@
|
||||
<script lang="ts">
|
||||
import { showCommandPalette, showSearch, theme, sourceMode } from '$lib/stores/app';
|
||||
import { showCommandPalette, showSearch, theme, sourceMode, viewMode, activeNotebook, activeTag } from '$lib/stores/app';
|
||||
import { setTheme, reindex } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
// onNavigate runs the parent's view-change handler (refreshes the note list and reveals it
|
||||
// if it was hidden), so opening a view here behaves exactly like clicking it in the sidebar.
|
||||
let { onNavigate = () => {} }: { onNavigate?: () => void } = $props();
|
||||
|
||||
function openView(mode: 'all' | 'quickaccess' | 'tasks' | 'daily' | 'trash') {
|
||||
$viewMode = mode;
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
$showCommandPalette = false;
|
||||
onNavigate();
|
||||
}
|
||||
|
||||
interface Command {
|
||||
id: string;
|
||||
@@ -25,6 +38,31 @@
|
||||
$showSearch = true;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'open-all-notes',
|
||||
label: 'Open All Notes',
|
||||
action: () => openView('all')
|
||||
},
|
||||
{
|
||||
id: 'open-quick-access',
|
||||
label: 'Open Quick Access',
|
||||
action: () => openView('quickaccess')
|
||||
},
|
||||
{
|
||||
id: 'open-tasks',
|
||||
label: 'Open Tasks',
|
||||
action: () => openView('tasks')
|
||||
},
|
||||
{
|
||||
id: 'open-daily',
|
||||
label: 'Open Daily Notes',
|
||||
action: () => openView('daily')
|
||||
},
|
||||
{
|
||||
id: 'open-trash',
|
||||
label: 'Open Trash (restore deleted notes)',
|
||||
action: () => openView('trash')
|
||||
},
|
||||
{
|
||||
id: 'theme-light',
|
||||
label: 'Switch to Light Theme',
|
||||
@@ -55,6 +93,46 @@
|
||||
$showCommandPalette = false;
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'theme-solarized-light',
|
||||
label: 'Switch to Solarized Light Theme',
|
||||
action: () => { $theme = 'solarized-light'; setTheme('solarized-light'); applyTheme('solarized-light'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-solarized-dark',
|
||||
label: 'Switch to Solarized Dark Theme',
|
||||
action: () => { $theme = 'solarized-dark'; setTheme('solarized-dark'); applyTheme('solarized-dark'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-catppuccin',
|
||||
label: 'Switch to Catppuccin Theme',
|
||||
action: () => { $theme = 'catppuccin'; setTheme('catppuccin'); applyTheme('catppuccin'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-nord',
|
||||
label: 'Switch to Nord Theme',
|
||||
action: () => { $theme = 'nord'; setTheme('nord'); applyTheme('nord'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-tokyo-night',
|
||||
label: 'Switch to Tokyo Night Theme',
|
||||
action: () => { $theme = 'tokyo-night'; setTheme('tokyo-night'); applyTheme('tokyo-night'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-github-light',
|
||||
label: 'Switch to GitHub Light Theme',
|
||||
action: () => { $theme = 'github-light'; setTheme('github-light'); applyTheme('github-light'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-github-dark',
|
||||
label: 'Switch to GitHub Dark Theme',
|
||||
action: () => { $theme = 'github-dark'; setTheme('github-dark'); applyTheme('github-dark'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'theme-dracula',
|
||||
label: 'Switch to Dracula Theme',
|
||||
action: () => { $theme = 'dracula'; setTheme('dracula'); applyTheme('dracula'); $showCommandPalette = false; }
|
||||
},
|
||||
{
|
||||
id: 'toggle-source',
|
||||
label: 'Toggle Source/WYSIWYG Mode',
|
||||
@@ -102,9 +180,14 @@
|
||||
}
|
||||
|
||||
function applyTheme(t: string) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,13 @@
|
||||
import { onDestroy } from 'svelte';
|
||||
import { getGraphData } from '$lib/api';
|
||||
import { activeNotePath, appConfig } from '$lib/stores/app';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let { onclose, onnavigate }: {
|
||||
onclose: () => void;
|
||||
onnavigate: (path: string, title: string) => void;
|
||||
} = $props();
|
||||
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
let canvas = $state<HTMLCanvasElement>(null!);
|
||||
let loading = $state(true);
|
||||
let searchQuery = $state('');
|
||||
@@ -76,8 +75,10 @@
|
||||
}
|
||||
|
||||
function getFolderKey(path: string): string {
|
||||
const vaultRoot = $appConfig?.active_vault || '';
|
||||
const rel = vaultRoot && path.startsWith(vaultRoot + '/') ? path.slice(vaultRoot.length + 1) : path;
|
||||
// Normalize Windows backslashes so folder grouping/colours work there too.
|
||||
const p = (path || '').replace(/\\/g, '/');
|
||||
const vaultRoot = ($appConfig?.active_vault || '').replace(/\\/g, '/');
|
||||
const rel = vaultRoot && p.startsWith(vaultRoot + '/') ? p.slice(vaultRoot.length + 1) : p;
|
||||
const slash = rel.indexOf('/');
|
||||
return slash >= 0 ? rel.slice(0, slash) : '';
|
||||
}
|
||||
|
||||
@@ -1,22 +1,102 @@
|
||||
<script lang="ts">
|
||||
import { showInfo, appConfig } from '$lib/stores/app';
|
||||
import { getVaultStats } from '$lib/api';
|
||||
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
|
||||
import type { OrphanAttachment } from '$lib/api';
|
||||
import { openUrl } from '$lib/api';
|
||||
import { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
import {
|
||||
ACTIONS,
|
||||
keybindings,
|
||||
setBinding,
|
||||
resetBinding,
|
||||
bindingFromEvent,
|
||||
bindingToKeys,
|
||||
bindingsEqual,
|
||||
type ActionDef,
|
||||
} from '$lib/keybindings';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
// Customizable shortcuts, grouped for display.
|
||||
const actionGroups: { title: string; actions: ActionDef[] }[] = [
|
||||
{ title: 'General', actions: ACTIONS.filter((a) => a.group === 'General') },
|
||||
{ title: 'Interface', actions: ACTIONS.filter((a) => a.group === 'Interface') },
|
||||
{ title: 'Navigation', actions: ACTIONS.filter((a) => a.group === 'Navigation') },
|
||||
];
|
||||
|
||||
// id of the action currently listening for a new key combo, or null.
|
||||
let capturingId = $state<string | null>(null);
|
||||
// id of an action whose row should briefly flash a "already in use" warning.
|
||||
let conflictId = $state<string | null>(null);
|
||||
let conflictTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function startCapture(id: string) {
|
||||
conflictId = null;
|
||||
capturingId = id;
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
capturingId = null;
|
||||
}
|
||||
|
||||
function flashConflict(id: string) {
|
||||
conflictId = id;
|
||||
if (conflictTimer) clearTimeout(conflictTimer);
|
||||
conflictTimer = setTimeout(() => { conflictId = null; }, 1600);
|
||||
}
|
||||
|
||||
function captureKeydown(e: KeyboardEvent) {
|
||||
if (!capturingId) return;
|
||||
// Capture phase + stopImmediatePropagation keeps the combo from also
|
||||
// triggering the app-level shortcut handler on window.
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
if (e.key === 'Escape') { stopCapture(); return; }
|
||||
|
||||
const binding = bindingFromEvent(e);
|
||||
if (!binding) return; // modifier-only press; keep listening
|
||||
|
||||
// Reject combos already bound to a different action.
|
||||
const map = $keybindings;
|
||||
const target = capturingId;
|
||||
const clash = Object.keys(map).find((other) => other !== target && bindingsEqual(map[other], binding));
|
||||
if (clash) {
|
||||
flashConflict(target);
|
||||
stopCapture();
|
||||
return;
|
||||
}
|
||||
|
||||
setBinding(target, binding);
|
||||
stopCapture();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!capturingId) return;
|
||||
window.addEventListener('keydown', captureKeydown, true);
|
||||
return () => window.removeEventListener('keydown', captureKeydown, true);
|
||||
});
|
||||
|
||||
function resetKey(e: MouseEvent, id: string) {
|
||||
e.preventDefault();
|
||||
resetBinding(id);
|
||||
if (capturingId === id) stopCapture();
|
||||
}
|
||||
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
let appVersion = $state('...');
|
||||
let orphans = $state<OrphanAttachment[] | null>(null);
|
||||
let scanning = $state(false);
|
||||
let trashing = $state(false);
|
||||
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
|
||||
|
||||
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
|
||||
|
||||
function close() {
|
||||
$showInfo = false;
|
||||
activeTab = 'shortcuts';
|
||||
activeTab = isMobile ? 'about' : 'shortcuts';
|
||||
}
|
||||
|
||||
function openLink(url: string) {
|
||||
@@ -35,8 +115,26 @@
|
||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||
} else {
|
||||
stats = null;
|
||||
orphans = null;
|
||||
}
|
||||
});
|
||||
|
||||
async function scanOrphans() {
|
||||
scanning = true;
|
||||
try { orphans = await findOrphanedAttachments(); }
|
||||
catch (e) { console.error(e); }
|
||||
finally { scanning = false; }
|
||||
}
|
||||
async function trashOrphans() {
|
||||
if (!orphans || orphans.length === 0) return;
|
||||
trashing = true;
|
||||
try {
|
||||
await trashOrphanedAttachments(orphans.map((o) => o.name));
|
||||
orphans = null;
|
||||
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
|
||||
} catch (e) { console.error(e); }
|
||||
finally { trashing = false; }
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if $showInfo}
|
||||
@@ -103,6 +201,25 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if stats}
|
||||
<div class="info-cleanup">
|
||||
{#if orphans === null}
|
||||
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
|
||||
{:else if orphans.length === 0}
|
||||
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
|
||||
{:else}
|
||||
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
|
||||
<div class="cleanup-list">
|
||||
{#each orphans as o}
|
||||
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
|
||||
{/each}
|
||||
</div>
|
||||
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
|
||||
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="info-credits">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
|
||||
@@ -116,27 +233,39 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div class="shortcuts-section">
|
||||
<h4 class="shortcuts-group-title">General</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">New note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Save</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>S</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Quick open</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>P</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Find in note</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Search vault</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>F</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Open note in new window</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>W</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Interface</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle sidebar</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>\</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle dark / light theme</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>N</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle focus mode</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>E</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle read-only</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle source / WYSIWYG</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>M</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Toggle fullscreen</span><span class="shortcut-keys"><kbd>F11</kbd></span></div>
|
||||
{#if !isMobile}
|
||||
<p class="shortcuts-hint">Click a shortcut to rebind it, right-click to reset.</p>
|
||||
{/if}
|
||||
{#each actionGroups as group}
|
||||
<h4 class="shortcuts-group-title">{group.title}</h4>
|
||||
{#each group.actions as action}
|
||||
<div class="shortcut-row">
|
||||
<span class="shortcut-desc">{action.label}</span>
|
||||
{#if isMobile}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{:else}
|
||||
<button
|
||||
class="shortcut-key-btn"
|
||||
class:capturing={capturingId === action.id}
|
||||
class:conflict={conflictId === action.id}
|
||||
title="Click to rebind · right-click to reset"
|
||||
onclick={() => startCapture(action.id)}
|
||||
oncontextmenu={(e) => resetKey(e, action.id)}
|
||||
>
|
||||
{#if capturingId === action.id}
|
||||
<span class="capture-prompt">Press keys…</span>
|
||||
{:else if conflictId === action.id}
|
||||
<span class="conflict-msg">Already in use</span>
|
||||
{:else}
|
||||
<span class="shortcut-keys">{#each bindingToKeys($keybindings[action.id]) as key, i}{#if i > 0}+{/if}<kbd>{key}</kbd>{/each}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/each}
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Close panel / exit focus</span><span class="shortcut-keys"><kbd>Esc</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Navigation</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go back</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>←</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Go forward</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>→</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Formatting</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Bold</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>B</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Italic</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>I</kbd></span></div>
|
||||
@@ -147,6 +276,7 @@
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Undo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Redo</span><span class="shortcut-keys"><kbd>{modKey}</kbd>+<kbd>Shift</kbd>+<kbd>Z</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move line up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Move list item up / down</span><span class="shortcut-keys"><kbd>Alt</kbd>+<kbd>Shift</kbd>+<kbd>↑↓</kbd></span></div>
|
||||
|
||||
<h4 class="shortcuts-group-title">Editor Commands</h4>
|
||||
<div class="shortcut-row"><span class="shortcut-desc">Slash commands</span><span class="shortcut-keys"><kbd>/</kbd></span></div>
|
||||
@@ -350,6 +480,71 @@
|
||||
.info-link:hover {
|
||||
background: var(--accent-light);
|
||||
}
|
||||
.info-cleanup {
|
||||
width: 100%;
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.cleanup-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
padding: 7px 14px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
|
||||
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
|
||||
.cleanup-msg {
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
text-align: center;
|
||||
}
|
||||
.cleanup-list {
|
||||
width: 100%;
|
||||
max-height: 140px;
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.cleanup-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 5px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.cleanup-name {
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cleanup-size {
|
||||
color: var(--text-tertiary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cleanup-hint {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
text-align: center;
|
||||
}
|
||||
.spinner-icon {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.shortcuts-section {
|
||||
width: 100%;
|
||||
@@ -394,6 +589,48 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.shortcuts-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 0 4px;
|
||||
}
|
||||
|
||||
.shortcut-key-btn {
|
||||
background: none;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
padding: 2px 4px;
|
||||
margin: -2px -4px;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.shortcut-key-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.capturing {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.shortcut-key-btn.conflict {
|
||||
border-color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.capture-prompt {
|
||||
font-size: 12px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.conflict-msg {
|
||||
font-size: 12px;
|
||||
color: var(--danger, #e11d48);
|
||||
}
|
||||
|
||||
.shortcut-keys kbd,
|
||||
.shortcut-desc kbd {
|
||||
display: inline-block;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
notes,
|
||||
sortedNotes,
|
||||
@@ -8,9 +9,12 @@
|
||||
activeNotebook,
|
||||
activeTag,
|
||||
sortMode,
|
||||
groupNotesByDate,
|
||||
editorDirty,
|
||||
quickAccessPaths,
|
||||
appConfig,
|
||||
notelistCollapsed,
|
||||
noteOrder,
|
||||
notebooks,
|
||||
tags,
|
||||
mobileView
|
||||
@@ -34,21 +38,25 @@
|
||||
getAllTags,
|
||||
createDailyNote
|
||||
} from '$lib/api';
|
||||
import { formatRelativeTime } from '$lib/utils/time';
|
||||
import { formatRelativeTime, formatDate, dateBucketLabel } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
import { revealItemInDir } from '@tauri-apps/plugin-opener';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
|
||||
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
|
||||
import TasksView from './TasksView.svelte';
|
||||
import TagSuggestInput from './TagSuggestInput.svelte';
|
||||
import { isMobile, isAndroid } from '$lib/platform';
|
||||
|
||||
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
|
||||
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onNoteSelected?: (path: string, content: string) => void;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => void;
|
||||
onNoteCreated?: () => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
let multiSelectMode = $state(false);
|
||||
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
|
||||
let trashBusy = $state<string | null>(null);
|
||||
@@ -59,14 +67,15 @@
|
||||
let dailyDates = $state<Set<string>>(new Set());
|
||||
|
||||
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
const calDayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
|
||||
// 0 = Sunday, 1 = Monday (default). Day names + grid offset follow the setting.
|
||||
const weekStartsOn = $derived($appConfig?.week_start === 'sunday' ? 0 : 1);
|
||||
const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
|
||||
|
||||
function calDays(): Array<{ day: number; date: string; current: boolean }> {
|
||||
const first = new Date(calYear, calMonth, 1);
|
||||
const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
// Monday=0 offset
|
||||
let startDow = first.getDay() - 1;
|
||||
if (startDow < 0) startDow = 6;
|
||||
const startDow = (first.getDay() - weekStartsOn + 7) % 7;
|
||||
const cells: Array<{ day: number; date: string; current: boolean }> = [];
|
||||
for (let i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
|
||||
for (let d = 1; d <= lastDay; d++) {
|
||||
@@ -127,18 +136,19 @@
|
||||
if (isMobile) {
|
||||
deriveTagsFromNotes();
|
||||
} else {
|
||||
await refreshTags();
|
||||
try { $tags = await getAllTags(); } catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
let compact = $derived($appConfig?.compact_notes ?? false);
|
||||
let showDates = $derived($appConfig?.show_note_dates !== false);
|
||||
let contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null);
|
||||
let sortMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let editingNote = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameInput: HTMLInputElement | null = $state(null);
|
||||
let movePickerNote = $state<NoteEntry | null>(null);
|
||||
let tagEditNote = $state<NoteEntry | null>(null);
|
||||
let tagEditValue = $state('');
|
||||
let tagEditTags = $state<string[]>([]);
|
||||
|
||||
// Multi-select
|
||||
@@ -197,6 +207,29 @@
|
||||
let qaDragFrom = $state<number | null>(null);
|
||||
let qaDragOver = $state<number | null>(null);
|
||||
let qaDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||
let noteDragFrom = $state<number | null>(null);
|
||||
let noteDragOver = $state<number | null>(null);
|
||||
let noteDragHalf = $state<'top' | 'bottom'>('bottom');
|
||||
const customOrderViews = new Set(['all', 'notebook', 'tag']);
|
||||
let canCustomReorder = $derived($sortMode === 'custom' && customOrderViews.has($viewMode));
|
||||
|
||||
// Relative-date grouping (All Notes + date sorts only). Pinned notes form their own leading group.
|
||||
let groupingActive = $derived($groupNotesByDate && $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified'));
|
||||
let noteGroups = $derived.by(() => {
|
||||
if (!groupingActive) return [] as { label: string; notes: NoteEntry[] }[];
|
||||
const pinned: NoteEntry[] = [];
|
||||
const rest: NoteEntry[] = [];
|
||||
for (const n of $sortedNotes) (n.meta.pinned ? pinned : rest).push(n);
|
||||
const groups: { label: string; notes: NoteEntry[] }[] = [];
|
||||
if (pinned.length) groups.push({ label: 'Pinned', notes: pinned });
|
||||
let cur: { label: string; notes: NoteEntry[] } | null = null;
|
||||
for (const n of rest) {
|
||||
const label = dateBucketLabel($sortMode === 'created' ? n.meta.created : n.meta.modified);
|
||||
if (!cur || cur.label !== label) { cur = { label, notes: [] }; groups.push(cur); }
|
||||
cur.notes.push(n);
|
||||
}
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Virtual scroll
|
||||
let listContainer = $state<HTMLDivElement>(null!);
|
||||
@@ -280,6 +313,7 @@
|
||||
if ($viewMode === 'tag') return `#${$activeTag}`;
|
||||
if ($viewMode === 'quickaccess') return 'Quick Access';
|
||||
if ($viewMode === 'daily') return 'Daily Notes';
|
||||
if ($viewMode === 'tasks') return 'Tasks';
|
||||
if ($viewMode === 'trash') return 'Trash';
|
||||
return 'Notes';
|
||||
});
|
||||
@@ -297,12 +331,15 @@
|
||||
}
|
||||
|
||||
export async function refresh(invalidate = false) {
|
||||
if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data
|
||||
if (invalidate) {
|
||||
noteCache.clear();
|
||||
}
|
||||
|
||||
const key = cacheKey();
|
||||
const cached = noteCache.get(key);
|
||||
// Never serve the trash view from cache: emptying / restoring / deleting changes it,
|
||||
// and a stale cache left it showing cleared items until an app restart. Always re-read.
|
||||
const cached = $viewMode === 'trash' ? undefined : noteCache.get(key);
|
||||
if (cached) {
|
||||
$notes = cached;
|
||||
return;
|
||||
@@ -365,6 +402,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function openTask(task: TaskItem) {
|
||||
try {
|
||||
const content = await readNote(task.note_path);
|
||||
onBeforeNoteSwitch();
|
||||
$activeNote = content;
|
||||
$activeNotePath = task.note_path;
|
||||
$editorDirty = false;
|
||||
onNoteSelected(task.note_path, content.content);
|
||||
} catch (e) {
|
||||
console.error('Failed to open task note:', e);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleCreateNote() {
|
||||
if ($viewMode === 'daily') {
|
||||
const today = new Date();
|
||||
@@ -374,9 +424,22 @@
|
||||
calYear = today.getFullYear();
|
||||
return;
|
||||
}
|
||||
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
|
||||
// Target: active notebook, else the open note's folder, else root.
|
||||
let nbRelative: string | null = null;
|
||||
if ($viewMode === 'notebook' && $activeNotebook) {
|
||||
nbRelative = $activeNotebook.relative_path || null;
|
||||
} else if ($appConfig?.active_vault && $activeNotePath) {
|
||||
const vaultN = $appConfig.active_vault.replace(/\\/g, '/');
|
||||
const apN = $activeNotePath.replace(/\\/g, '/');
|
||||
if (apN.startsWith(vaultN + '/')) {
|
||||
const rel = apN.slice(vaultN.length + 1);
|
||||
const slash = rel.lastIndexOf('/');
|
||||
if (slash > 0) nbRelative = rel.slice(0, slash);
|
||||
}
|
||||
}
|
||||
try {
|
||||
const entry = await createNote(nbRelative, 'Untitled');
|
||||
if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
|
||||
noteCache.clear();
|
||||
await refresh();
|
||||
await selectNote(entry);
|
||||
@@ -386,6 +449,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleListDoubleClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
|
||||
e.stopPropagation();
|
||||
if ($viewMode === 'tasks' || $viewMode === 'trash') return;
|
||||
handleCreateNote();
|
||||
}
|
||||
|
||||
async function handleRename(note: NoteEntry) {
|
||||
if (!editValue.trim() || editValue.trim() === note.meta.title) {
|
||||
editingNote = null;
|
||||
@@ -394,6 +465,7 @@
|
||||
try {
|
||||
const newTitle = editValue.trim();
|
||||
const newPath = await renameNote(note.path, newTitle);
|
||||
renameManualNoteOrderPath(note.path, newPath);
|
||||
noteCache.clear();
|
||||
editingNote = null;
|
||||
// Update local store immediately (avoid full vault re-scan)
|
||||
@@ -415,6 +487,7 @@
|
||||
contextMenu = null;
|
||||
try {
|
||||
await deleteNote(note.path);
|
||||
removeManualNoteOrderPath(note.path);
|
||||
noteCache.clear();
|
||||
// Remove from local store immediately (avoid full vault re-scan)
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
@@ -427,6 +500,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Move the currently open note to the trash (keyboard shortcut from the editor).
|
||||
export async function trashActiveNote() {
|
||||
if ($viewMode === 'trash' || !$activeNotePath) return;
|
||||
const note = $notes.find(n => n.path === $activeNotePath);
|
||||
if (note) await handleDelete(note);
|
||||
}
|
||||
|
||||
async function handleRestore(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
try {
|
||||
@@ -442,6 +522,7 @@
|
||||
contextMenu = null;
|
||||
try {
|
||||
await permanentDelete(note.path);
|
||||
removeManualNoteOrderPath(note.path);
|
||||
noteCache.clear();
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
if ($activeNotePath === note.path) {
|
||||
@@ -500,7 +581,7 @@
|
||||
function openTagEdit(note: NoteEntry) {
|
||||
tagEditNote = note;
|
||||
tagEditTags = [...note.meta.tags];
|
||||
tagEditValue = '';
|
||||
refreshTags();
|
||||
}
|
||||
|
||||
async function addTagToNote(tag: string) {
|
||||
@@ -508,7 +589,6 @@
|
||||
const cleaned = tag.trim().toLowerCase();
|
||||
if (tagEditTags.includes(cleaned)) return;
|
||||
tagEditTags = [...tagEditTags, cleaned];
|
||||
tagEditValue = '';
|
||||
await saveTagsForNote(tagEditNote, tagEditTags);
|
||||
}
|
||||
|
||||
@@ -537,7 +617,7 @@
|
||||
|
||||
function openBatchTagEdit() {
|
||||
batchTagEdit = true;
|
||||
tagEditValue = '';
|
||||
refreshTags();
|
||||
// Show tags common to ALL selected notes
|
||||
const selectedNotes = $notes.filter(n => selectedPaths.has(n.path));
|
||||
if (selectedNotes.length === 0) { tagEditTags = []; return; }
|
||||
@@ -549,7 +629,6 @@
|
||||
async function addTagToBatch(tag: string) {
|
||||
if (!tag.trim()) return;
|
||||
const cleaned = tag.trim().toLowerCase();
|
||||
tagEditValue = '';
|
||||
if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned];
|
||||
try {
|
||||
for (const path of selectedPaths) {
|
||||
@@ -637,11 +716,60 @@
|
||||
}
|
||||
}
|
||||
|
||||
function persistManualNoteOrder(orderedNotes: NoteEntry[]) {
|
||||
// Merge into (not replace) the order map so reordering notes in one view does
|
||||
// not wipe the saved custom order of other notebooks / tags / the all-notes view.
|
||||
const updates = Object.fromEntries(orderedNotes.map((note, index) => [note.path, index]));
|
||||
$noteOrder = { ...$noteOrder, ...updates };
|
||||
}
|
||||
|
||||
function appendManualNoteOrder(path: string) {
|
||||
const nextIndex = Math.max(-1, ...Object.values($noteOrder)) + 1;
|
||||
$noteOrder = { ...$noteOrder, [path]: nextIndex };
|
||||
}
|
||||
|
||||
function renameManualNoteOrderPath(oldPath: string, newPath: string) {
|
||||
if (!Object.hasOwn($noteOrder, oldPath)) return;
|
||||
const { [oldPath]: index, ...rest } = $noteOrder;
|
||||
$noteOrder = { ...rest, [newPath]: index };
|
||||
}
|
||||
|
||||
function removeManualNoteOrderPath(path: string) {
|
||||
if (!Object.hasOwn($noteOrder, path)) return;
|
||||
const { [path]: _, ...rest } = $noteOrder;
|
||||
$noteOrder = rest;
|
||||
}
|
||||
|
||||
function resetNoteDrag() {
|
||||
noteDragFrom = null;
|
||||
noteDragOver = null;
|
||||
}
|
||||
|
||||
function handleCustomNoteDrop(targetIndex: number) {
|
||||
if (noteDragFrom === null || !canCustomReorder) {
|
||||
resetNoteDrag();
|
||||
return;
|
||||
}
|
||||
let insertAt = noteDragHalf === 'bottom' ? targetIndex + 1 : targetIndex;
|
||||
if (noteDragFrom === insertAt || noteDragFrom + 1 === insertAt) {
|
||||
resetNoteDrag();
|
||||
return;
|
||||
}
|
||||
const arr = [...$sortedNotes];
|
||||
const [moved] = arr.splice(noteDragFrom, 1);
|
||||
if (insertAt > noteDragFrom) insertAt--;
|
||||
arr.splice(insertAt, 0, moved);
|
||||
$notes = arr;
|
||||
persistManualNoteOrder(arr);
|
||||
resetNoteDrag();
|
||||
}
|
||||
|
||||
async function handleMoveNote(note: NoteEntry, destPath: string) {
|
||||
contextMenu = null;
|
||||
movePickerNote = null;
|
||||
try {
|
||||
const newPath = await moveNote(note.path, destPath);
|
||||
renameManualNoteOrderPath(note.path, newPath);
|
||||
noteCache.clear();
|
||||
$notes = $notes.filter(n => n.path !== note.path);
|
||||
if ($activeNotePath === note.path) {
|
||||
@@ -668,10 +796,13 @@
|
||||
contextMenu = { x, y, note };
|
||||
}
|
||||
|
||||
function startRename(note: NoteEntry) {
|
||||
async function startRename(note: NoteEntry) {
|
||||
contextMenu = null;
|
||||
editingNote = note.path;
|
||||
editValue = note.meta.title;
|
||||
await tick();
|
||||
renameInput?.focus();
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
function getNotebookPath(note: NoteEntry): string {
|
||||
@@ -740,6 +871,7 @@
|
||||
async function handleBatchDelete() {
|
||||
const toDelete = new Set(selectedPaths);
|
||||
noteCache.clear();
|
||||
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||
$activeNote = null;
|
||||
@@ -775,6 +907,7 @@
|
||||
async function handleBatchPermanentDelete() {
|
||||
const toDelete = new Set(selectedPaths);
|
||||
noteCache.clear();
|
||||
for (const path of toDelete) removeManualNoteOrderPath(path);
|
||||
$notes = $notes.filter(n => !toDelete.has(n.path));
|
||||
if ($activeNotePath && toDelete.has($activeNotePath)) {
|
||||
$activeNote = null;
|
||||
@@ -800,6 +933,9 @@
|
||||
}
|
||||
|
||||
function setSortMode(mode: SortMode) {
|
||||
if (mode === 'custom' && Object.keys($noteOrder).length === 0) {
|
||||
persistManualNoteOrder($sortedNotes);
|
||||
}
|
||||
$sortMode = mode;
|
||||
sortMenu = null;
|
||||
}
|
||||
@@ -823,6 +959,17 @@
|
||||
<div class="list-header">
|
||||
<span class="list-title">{viewTitle}</span>
|
||||
<div class="list-actions">
|
||||
{#if !isMobile}
|
||||
<button class="icon-btn" onclick={() => ($notelistCollapsed = true)} title={`Hide notes list (${modKey}+Shift+\\)`} aria-label="Hide notes list">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="4" y1="6" x2="16" y2="6" />
|
||||
<line x1="4" y1="12" x2="16" y2="12" />
|
||||
<line x1="4" y1="18" x2="16" y2="18" />
|
||||
<polyline points="19 8 21 12 19 16" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $viewMode !== 'tasks'}
|
||||
{#if isAndroid}
|
||||
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -835,6 +982,13 @@
|
||||
<line x1="4" y1="6" x2="11" y2="6" /><line x1="4" y1="12" x2="15" y2="12" /><line x1="4" y1="18" x2="20" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if $viewMode === 'all' && ($sortMode === 'created' || $sortMode === 'modified')}
|
||||
<button class="icon-btn" class:active-toggle={$groupNotesByDate} onclick={() => { $groupNotesByDate = !$groupNotesByDate; }} title="Group by date">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" /><line x1="8" y1="12" x2="21" y2="12" /><line x1="8" y1="18" x2="21" y2="18" /><line x1="3" y1="6" x2="3.01" y2="6" /><line x1="3" y1="12" x2="3.01" y2="12" /><line x1="3" y1="18" x2="3.01" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{#if $viewMode !== 'trash'}
|
||||
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round">
|
||||
@@ -842,6 +996,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -906,16 +1061,11 @@
|
||||
</div>
|
||||
<div class="tag-edit-section">
|
||||
<div class="tag-edit-input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="tag-edit-input"
|
||||
<TagSuggestInput
|
||||
existing={tagEditTags}
|
||||
placeholder="Add tag to all..."
|
||||
bind:value={tagEditValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
|
||||
}}
|
||||
autofocus
|
||||
onsubmit={(t) => addTagToBatch(t)}
|
||||
oncancel={() => batchTagEdit = false}
|
||||
/>
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
@@ -937,7 +1087,11 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="list-content" bind:this={listContainer} onscroll={onListScroll} ondblclick={handleListDoubleClick}>
|
||||
{#if $viewMode === 'tasks'}
|
||||
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
|
||||
{/if}
|
||||
{#if $viewMode === 'daily'}
|
||||
<div class="cal">
|
||||
<div class="cal-nav">
|
||||
@@ -972,7 +1126,7 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
|
||||
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && $viewMode !== 'tasks' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
|
||||
<div class="empty-state">
|
||||
{#if $viewMode === 'trash'}
|
||||
<p>Trash is empty</p>
|
||||
@@ -1014,14 +1168,13 @@
|
||||
{/each}
|
||||
{/if}
|
||||
|
||||
<div style="height: {topPad}px"></div>
|
||||
{#each visibleNotes as note, i (note.path)}
|
||||
{@const noteIndex = startIndex + i}
|
||||
{#snippet noteRow(note: NoteEntry, noteIndex: number)}
|
||||
{#if editingNote === note.path}
|
||||
<div class="note-item active">
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
bind:this={renameInput}
|
||||
bind:value={editValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(note);
|
||||
@@ -1039,7 +1192,15 @@
|
||||
class:compact={compact}
|
||||
class:qa-drag-above={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'top'}
|
||||
class:qa-drag-below={$viewMode === 'quickaccess' && qaDragOver === noteIndex && qaDragFrom !== null && qaDragHalf === 'bottom'}
|
||||
class:note-drag-above={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'top'}
|
||||
class:note-drag-below={canCustomReorder && noteDragOver === noteIndex && noteDragFrom !== null && noteDragHalf === 'bottom'}
|
||||
onclick={(e) => handleNoteClick(e, note)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(note);
|
||||
}
|
||||
}}
|
||||
ontouchstart={(e) => { if (isMobile && !isAndroid) handleTouchStart(e, note); }}
|
||||
ontouchmove={(e) => { if (isMobile && !isAndroid) handleTouchMove(e); }}
|
||||
ontouchend={() => { if (isMobile && !isAndroid) handleTouchEnd(); }}
|
||||
@@ -1066,6 +1227,12 @@
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (canCustomReorder) {
|
||||
noteDragFrom = noteIndex;
|
||||
e.dataTransfer!.setData('text/plain', note.path);
|
||||
e.dataTransfer!.effectAllowed = 'move';
|
||||
return;
|
||||
}
|
||||
if (selectedPaths.size > 1 && selectedPaths.has(note.path)) {
|
||||
e.dataTransfer!.setData('text/plain', [...selectedPaths].join('\n'));
|
||||
} else {
|
||||
@@ -1080,18 +1247,28 @@
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
qaDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||
qaDragOver = noteIndex;
|
||||
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
e.dataTransfer!.dropEffect = 'move';
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
noteDragHalf = e.clientY < rect.top + rect.height / 2 ? 'top' : 'bottom';
|
||||
noteDragOver = noteIndex;
|
||||
}
|
||||
}}
|
||||
ondragleave={() => {
|
||||
if (qaDragOver === noteIndex) qaDragOver = null;
|
||||
if (noteDragOver === noteIndex) noteDragOver = null;
|
||||
}}
|
||||
ondrop={(e) => {
|
||||
if ($viewMode === 'quickaccess' && qaDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
handleQaDrop(noteIndex);
|
||||
} else if (canCustomReorder && noteDragFrom !== null) {
|
||||
e.preventDefault();
|
||||
handleCustomNoteDrop(noteIndex);
|
||||
}
|
||||
}}
|
||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; }}
|
||||
ondragend={() => { qaDragFrom = null; qaDragOver = null; resetNoteDrag(); }}
|
||||
>
|
||||
{#if compact}
|
||||
<div class="note-compact-row" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
||||
@@ -1111,7 +1288,7 @@
|
||||
{#if getNotebookPath(note)}
|
||||
<span class="note-notebook">{getNotebookPath(note)}</span>
|
||||
{/if}
|
||||
<span class="note-date-compact">{formatRelativeTime(note.meta.modified)}</span>
|
||||
{#if showDates}<span class="note-date-compact" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="note-title" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
|
||||
@@ -1131,9 +1308,9 @@
|
||||
<div class="note-meta">
|
||||
{#if getNotebookPath(note)}
|
||||
<span class="note-notebook">{getNotebookPath(note)}</span>
|
||||
<span class="note-meta-sep">·</span>
|
||||
{#if showDates}<span class="note-meta-sep">·</span>{/if}
|
||||
{/if}
|
||||
<span class="note-date">{formatRelativeTime(note.meta.modified)}</span>
|
||||
{#if showDates}<span class="note-date" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
|
||||
{#if note.meta.tags.length > 0}
|
||||
<span class="note-tags">
|
||||
{#each note.meta.tags.slice(0, 3) as tag}
|
||||
@@ -1145,8 +1322,22 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
<div style="height: {bottomPad}px"></div>
|
||||
{/snippet}
|
||||
|
||||
{#if groupingActive}
|
||||
{#each noteGroups as group (group.label)}
|
||||
<div class="note-list-section">{group.label}</div>
|
||||
{#each group.notes as note (note.path)}
|
||||
{@render noteRow(note, -1)}
|
||||
{/each}
|
||||
{/each}
|
||||
{:else}
|
||||
<div style="height: {topPad}px"></div>
|
||||
{#each visibleNotes as note, i (note.path)}
|
||||
{@render noteRow(note, startIndex + i)}
|
||||
{/each}
|
||||
<div style="height: {bottomPad}px"></div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1171,16 +1362,11 @@
|
||||
<div class="context-sep"></div>
|
||||
<div class="tag-edit-section">
|
||||
<div class="tag-edit-input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="tag-edit-input"
|
||||
<TagSuggestInput
|
||||
existing={tagEditTags}
|
||||
placeholder="Add tag to all..."
|
||||
bind:value={tagEditValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
|
||||
}}
|
||||
autofocus
|
||||
onsubmit={(t) => addTagToBatch(t)}
|
||||
oncancel={() => batchTagEdit = false}
|
||||
/>
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
@@ -1228,16 +1414,11 @@
|
||||
<div class="context-sep"></div>
|
||||
<div class="tag-edit-section">
|
||||
<div class="tag-edit-input-row">
|
||||
<input
|
||||
type="text"
|
||||
class="tag-edit-input"
|
||||
<TagSuggestInput
|
||||
existing={tagEditTags}
|
||||
placeholder="Add tag..."
|
||||
bind:value={tagEditValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') { e.preventDefault(); addTagToNote(tagEditValue); }
|
||||
if (e.key === 'Escape') { e.preventDefault(); tagEditNote = null; }
|
||||
}}
|
||||
autofocus
|
||||
onsubmit={(t) => addTagToNote(t)}
|
||||
oncancel={() => tagEditNote = null}
|
||||
/>
|
||||
</div>
|
||||
{#if tagEditTags.length > 0}
|
||||
@@ -1380,6 +1561,18 @@
|
||||
Title
|
||||
{#if $sortMode === 'title'}<span class="sort-check">✓</span>{/if}
|
||||
</button>
|
||||
<button class:active={$sortMode === 'custom'} onclick={() => setSortMode('custom')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="8" y1="6" x2="21" y2="6" />
|
||||
<line x1="8" y1="12" x2="21" y2="12" />
|
||||
<line x1="8" y1="18" x2="21" y2="18" />
|
||||
<circle cx="4" cy="6" r="1" />
|
||||
<circle cx="4" cy="12" r="1" />
|
||||
<circle cx="4" cy="18" r="1" />
|
||||
</svg>
|
||||
Custom
|
||||
{#if $sortMode === 'custom'}<span class="sort-check">✓</span>{/if}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
@@ -1454,6 +1647,21 @@
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.note-list-section {
|
||||
padding: 14px 12px 4px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--accent);
|
||||
border-bottom: 1px solid var(--border-light);
|
||||
margin-bottom: 2px;
|
||||
user-select: none;
|
||||
}
|
||||
.note-list-section:first-child {
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
.note-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
@@ -1820,21 +2028,6 @@
|
||||
padding: 2px 8px 4px;
|
||||
}
|
||||
|
||||
.tag-edit-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.tag-edit-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.tag-edit-list {
|
||||
max-height: 160px;
|
||||
overflow-y: auto;
|
||||
@@ -1917,11 +2110,13 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.note-item.qa-drag-above {
|
||||
.note-item.qa-drag-above,
|
||||
.note-item.note-drag-above {
|
||||
border-top: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
.note-item.qa-drag-below {
|
||||
.note-item.qa-drag-below,
|
||||
.note-item.note-drag-below {
|
||||
border-bottom: 2px solid var(--accent);
|
||||
}
|
||||
|
||||
@@ -2200,8 +2395,7 @@
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 4px 2px;
|
||||
padding: 0 2px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-head {
|
||||
@@ -2213,19 +2407,18 @@
|
||||
}
|
||||
|
||||
.cal-cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.72rem;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
font-size: 12px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: none;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.cal-cell.empty {
|
||||
@@ -2237,6 +2430,7 @@
|
||||
}
|
||||
|
||||
.cal-cell.today {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -12,10 +12,13 @@
|
||||
theme
|
||||
} from '$lib/stores/app';
|
||||
import { readNote } from '$lib/api';
|
||||
import { keybindings, matchAction } from '$lib/keybindings';
|
||||
import type { FileEvent } from '$lib/types';
|
||||
|
||||
let { notePath }: { notePath: string } = $props();
|
||||
|
||||
import { darkThemes } from '$lib/platform';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
let editor = $state<Editor>(null!);
|
||||
@@ -34,10 +37,15 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const themeValue = $theme || 'system';
|
||||
const t = $theme || 'system';
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
});
|
||||
@@ -66,13 +74,11 @@
|
||||
}
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
if (mod && !e.shiftKey && e.code === 'KeyS') {
|
||||
const action = matchAction(e, $keybindings);
|
||||
if (action === 'save') {
|
||||
e.preventDefault();
|
||||
editor?.forceSave();
|
||||
return;
|
||||
}
|
||||
if (mod && e.shiftKey && e.code === 'KeyM') {
|
||||
} else if (action === 'toggle-source') {
|
||||
e.preventDefault();
|
||||
$sourceMode = !$sourceMode;
|
||||
}
|
||||
@@ -97,8 +103,11 @@
|
||||
if (!$editorDirty) {
|
||||
try {
|
||||
const content = await readNote(notePath);
|
||||
$activeNote = content;
|
||||
editor?.loadNote(notePath, content.content);
|
||||
// Ignore the file-watcher echo of our own save; only reload genuine external edits.
|
||||
if (content.content.trim() !== (editor?.getCurrentBody() ?? '').trim()) {
|
||||
$activeNote = content;
|
||||
editor?.loadNote(notePath, content.content);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
type Dir =
|
||||
| 'North' | 'South' | 'East' | 'West'
|
||||
| 'NorthEast' | 'NorthWest' | 'SouthEast' | 'SouthWest';
|
||||
|
||||
// Resolved only on the client (onMount), so SSR/prerender never calls Tauri APIs.
|
||||
let appWindow = $state<ReturnType<typeof getCurrentWindow> | null>(null);
|
||||
let maximized = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (isMobile) return;
|
||||
const win = getCurrentWindow();
|
||||
appWindow = win;
|
||||
const refresh = () => win.isMaximized().then((m) => (maximized = m)).catch(() => {});
|
||||
refresh();
|
||||
const unlisten = win.onResized(() => refresh());
|
||||
return () => { unlisten.then((fn) => fn()); };
|
||||
});
|
||||
|
||||
function start(e: MouseEvent, dir: Dir) {
|
||||
if (e.button !== 0 || !appWindow) return;
|
||||
e.preventDefault();
|
||||
appWindow.startResizeDragging(dir);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if appWindow && !maximized}
|
||||
<div class="resize-layer" aria-hidden="true">
|
||||
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
|
||||
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
|
||||
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
|
||||
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
|
||||
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
|
||||
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
|
||||
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
|
||||
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
/* Transparent overlay; only the strips capture events, the rest passes through. */
|
||||
.resize-layer {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
.rz {
|
||||
position: fixed;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Edges, inset by the corner size so corners win at the corners. */
|
||||
.rz-n { top: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
|
||||
.rz-s { bottom: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
|
||||
.rz-w { left: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
|
||||
.rz-e { right: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
|
||||
|
||||
/* Corners: larger, reliable grab targets. */
|
||||
.rz-nw { top: 0; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
|
||||
.rz-ne { top: 0; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
|
||||
.rz-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }
|
||||
.rz-se { bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; }
|
||||
</style>
|
||||
@@ -3,6 +3,7 @@
|
||||
import { searchNotes, readNote } from '$lib/api';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import type { SearchResult } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<SearchResult[]>([]);
|
||||
@@ -83,8 +84,6 @@
|
||||
return str.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
async function openResult(result: SearchResult) {
|
||||
try {
|
||||
const content = await readNote(result.path);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { tick } from 'svelte';
|
||||
import {
|
||||
notebooks,
|
||||
notes,
|
||||
@@ -20,26 +21,47 @@
|
||||
notebookSortMode,
|
||||
notebookOrder
|
||||
} from '$lib/stores/app';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, getNotes, countRootNotes } from '$lib/api';
|
||||
import { getNotebooks, getAllTags, createNotebook, deleteNotebook, renameNotebook, moveNotebook, getNotebookIcons, setNotebookIcon, saveAttachment, getQuickAccess, addQuickAccess, removeQuickAccess, emptyTrash, moveNote, readNote, countRootNotes } from '$lib/api';
|
||||
import { open as openDialog } from '@tauri-apps/plugin-dialog';
|
||||
import { readFile } from '@tauri-apps/plugin-fs';
|
||||
import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import type { NotebookEntry } from '$lib/types';
|
||||
import { isMobile } from '$lib/platform';
|
||||
|
||||
let { onViewChanged = () => {} }: {
|
||||
onViewChanged?: () => void;
|
||||
} = $props();
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
// Whether any top nav item (All Notes, Quick Access, Tasks, Daily Notes, Trash) is visible.
|
||||
// When all are hidden we drop the empty <nav> and its divider so the tree sits flush.
|
||||
const anyNavItem = $derived(
|
||||
$appConfig?.show_all_notes !== false ||
|
||||
$appConfig?.show_quick_access !== false ||
|
||||
$appConfig?.show_tasks !== false ||
|
||||
$appConfig?.show_daily_notes !== false ||
|
||||
$appConfig?.show_trash !== false
|
||||
);
|
||||
|
||||
let editingNotebook = $state<string | null>(null);
|
||||
let editValue = $state('');
|
||||
let renameInput: HTMLInputElement | null = $state(null);
|
||||
let newNotebookName = $state('');
|
||||
let showNewNotebook = $state(false);
|
||||
let newNotebookInput: HTMLInputElement | null = $state(null);
|
||||
let dropTargetPath = $state<string | null>(null);
|
||||
let dropPosition = $state<'above' | 'into' | 'below' | null>(null);
|
||||
let draggedNotebookPath = $state<string | null>(null);
|
||||
let nbPointer: { src: string } | null = null;
|
||||
|
||||
// Notebook paths arrive from Rust with native separators (backslashes on Windows), so path math must be separator-agnostic.
|
||||
const norm = (p: string) => (p ?? '').replace(/\\/g, '/');
|
||||
const lastSep = (p: string) => Math.max(p.lastIndexOf('/'), p.lastIndexOf('\\'));
|
||||
const parentOf = (p: string) => { const i = lastSep(p); return i < 0 ? '' : p.slice(0, i); };
|
||||
const baseOf = (p: string) => p.slice(lastSep(p) + 1);
|
||||
const joinPath = (parent: string, name: string) => parent + (parent.includes('\\') ? '\\' : '/') + name;
|
||||
const isDescendant = (child: string, anc: string) => child.startsWith(anc + '/') || child.startsWith(anc + '\\');
|
||||
|
||||
// Apply manual ordering recursively when in 'manual' sort mode.
|
||||
// In 'alphabetical' mode the Rust scanner already sorts, so we pass through unchanged.
|
||||
@@ -59,14 +81,30 @@
|
||||
let trashContextMenu = $state<{ x: number; y: number } | null>(null);
|
||||
let tagsCollapsed = $state(true);
|
||||
let deleteConfirm = $state<NotebookEntry | null>(null);
|
||||
function toggleCollapse(path: string, e: MouseEvent) {
|
||||
function toggleCollapse(nb: NotebookEntry, e: Event) {
|
||||
e.stopPropagation();
|
||||
if ($collapsedNotebooks.includes(path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== path);
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
} else {
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, path];
|
||||
$collapsedNotebooks = [...$collapsedNotebooks, nb.path];
|
||||
}
|
||||
}
|
||||
// Collapse-all / expand-all: toggles every notebook that has children.
|
||||
function collectParentPaths(tree: NotebookEntry[]): string[] {
|
||||
const out: string[] = [];
|
||||
for (const nb of tree) {
|
||||
if (nb.children.length > 0) {
|
||||
out.push(nb.path);
|
||||
out.push(...collectParentPaths(nb.children));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const allParentPaths = $derived(collectParentPaths($notebooks));
|
||||
const allCollapsed = $derived(allParentPaths.length > 0 && allParentPaths.every((p) => $collapsedNotebooks.includes(p)));
|
||||
function toggleAllCollapse() {
|
||||
$collapsedNotebooks = allCollapsed ? [] : [...allParentPaths];
|
||||
}
|
||||
|
||||
|
||||
export async function refresh() {
|
||||
@@ -117,8 +155,8 @@
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectNotebook(nb: NotebookEntry) {
|
||||
// Deselect if clicking the already-active notebook
|
||||
async function selectNotebook(nb: NotebookEntry) {
|
||||
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
|
||||
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
|
||||
selectAllNotes();
|
||||
return;
|
||||
@@ -150,6 +188,13 @@
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectTasks() {
|
||||
$viewMode = 'tasks';
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectTrash() {
|
||||
$viewMode = 'trash';
|
||||
$activeNotebook = null;
|
||||
@@ -159,11 +204,52 @@
|
||||
|
||||
let newNotebookParent = $state<NotebookEntry | null>(null);
|
||||
|
||||
function getNotebookNameSegments(name: string): string[] | null {
|
||||
const segments = name.split('/').map((segment) => segment.trim());
|
||||
if (segments.some((segment) => !segment || segment === '.' || segment === '..' || segment.includes('\\'))) {
|
||||
return null;
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
async function focusNewNotebookInput() {
|
||||
await tick();
|
||||
newNotebookInput?.focus();
|
||||
}
|
||||
|
||||
async function toggleNewNotebookInput() {
|
||||
if (showNewNotebook) {
|
||||
showNewNotebook = false;
|
||||
newNotebookName = '';
|
||||
newNotebookParent = null;
|
||||
return;
|
||||
}
|
||||
|
||||
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||
showNewNotebook = true;
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function startNewNotebookFromSidebar(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (target?.closest('button, input, .context-menu, .delete-confirm-overlay')) return;
|
||||
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
|
||||
newNotebookName = '';
|
||||
showNewNotebook = true;
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function handleCreateNotebook() {
|
||||
if (!newNotebookName.trim()) return;
|
||||
try {
|
||||
const trimmedName = newNotebookName.trim();
|
||||
const parentRel = newNotebookParent?.relative_path ?? null;
|
||||
await createNotebook(parentRel, newNotebookName.trim());
|
||||
const name = parentRel ? trimmedName : getNotebookNameSegments(trimmedName)?.join('/');
|
||||
if (!name) return;
|
||||
|
||||
await createNotebook(parentRel, name);
|
||||
newNotebookName = '';
|
||||
showNewNotebook = false;
|
||||
newNotebookParent = null;
|
||||
@@ -173,7 +259,24 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startNewSubNotebook(nb: NotebookEntry) {
|
||||
function handleNewNotebookKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateNotebook();
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Escape') {
|
||||
showNewNotebook = false;
|
||||
newNotebookName = '';
|
||||
newNotebookParent = null;
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' && newNotebookParent && newNotebookName.length === 0) {
|
||||
e.preventDefault();
|
||||
newNotebookParent = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function startNewSubNotebook(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
newNotebookParent = nb;
|
||||
newNotebookName = '';
|
||||
@@ -182,6 +285,7 @@
|
||||
if ($collapsedNotebooks.includes(nb.path)) {
|
||||
$collapsedNotebooks = $collapsedNotebooks.filter(p => p !== nb.path);
|
||||
}
|
||||
await focusNewNotebookInput();
|
||||
}
|
||||
|
||||
async function handleRename(nb: NotebookEntry) {
|
||||
@@ -248,30 +352,34 @@
|
||||
|
||||
async function handleNotebookDrop(e: DragEvent, destPath: string) {
|
||||
e.preventDefault();
|
||||
dropTargetPath = null;
|
||||
const srcPath = e.dataTransfer?.getData('text/plain');
|
||||
if (!srcPath) return;
|
||||
if (srcPath) await nestNotebook(srcPath, destPath);
|
||||
}
|
||||
|
||||
async function nestNotebook(srcPath: string, destPath: string) {
|
||||
dropTargetPath = null;
|
||||
dropPosition = null;
|
||||
draggedNotebookPath = null;
|
||||
// Don't move onto self or descendant
|
||||
if (destPath === srcPath || destPath.startsWith(srcPath + '/')) return;
|
||||
if (destPath === srcPath || isDescendant(destPath, srcPath)) return;
|
||||
// Don't move if already in that parent
|
||||
const parentDir = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
||||
const parentDir = parentOf(srcPath);
|
||||
if (parentDir === destPath) return;
|
||||
try {
|
||||
const oldName = srcPath.split('/').pop() || '';
|
||||
const newBasePath = destPath + '/' + oldName;
|
||||
const oldName = baseOf(srcPath);
|
||||
const newBasePath = joinPath(destPath, oldName);
|
||||
await moveNotebook(srcPath, destPath);
|
||||
// Update collapsedNotebooks paths
|
||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||
if (p === srcPath) return newBasePath;
|
||||
if (p.startsWith(srcPath + '/')) return newBasePath + p.slice(srcPath.length);
|
||||
if (isDescendant(p, srcPath)) return newBasePath + p.slice(srcPath.length);
|
||||
return p;
|
||||
});
|
||||
// Update active notebook/note if inside the moved notebook
|
||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
||||
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||
selectAllNotes();
|
||||
}
|
||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
||||
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||
const newNotePath = newBasePath + $activeNotePath.slice(srcPath.length);
|
||||
$activeNotePath = newNotePath;
|
||||
$activeNote = await readNote(newNotePath);
|
||||
@@ -283,10 +391,74 @@
|
||||
}
|
||||
}
|
||||
|
||||
function nbStopDragListeners() {
|
||||
window.removeEventListener('pointermove', nbPointerMove);
|
||||
window.removeEventListener('pointerup', nbDragEnd);
|
||||
window.removeEventListener('pointercancel', nbDragEnd);
|
||||
window.removeEventListener('mouseup', nbDragEnd);
|
||||
}
|
||||
|
||||
function nbHandleDown(e: PointerEvent, nb: NotebookEntry) {
|
||||
if (e.pointerType === 'mouse' && e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
nbStopDragListeners();
|
||||
nbPointer = { src: nb.path };
|
||||
draggedNotebookPath = nb.path;
|
||||
// Capture helps touch hold the gesture; on Windows mouse it makes WebView2 swallow the release, so skip it there.
|
||||
if (e.pointerType === 'touch') {
|
||||
try { (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); } catch {}
|
||||
}
|
||||
window.addEventListener('pointermove', nbPointerMove, { passive: false });
|
||||
window.addEventListener('pointerup', nbDragEnd);
|
||||
window.addEventListener('pointercancel', nbDragEnd);
|
||||
window.addEventListener('mouseup', nbDragEnd);
|
||||
}
|
||||
|
||||
function nbPointerMove(e: PointerEvent) {
|
||||
if (!nbPointer) return;
|
||||
e.preventDefault();
|
||||
const el = document.elementFromPoint(e.clientX, e.clientY) as HTMLElement | null;
|
||||
if (el?.closest('[data-nb-root]')) { dropTargetPath = '__root__'; dropPosition = null; return; }
|
||||
const row = el?.closest('[data-nb-path]') as HTMLElement | null;
|
||||
const path = row?.getAttribute('data-nb-path');
|
||||
if (!path) { dropTargetPath = null; dropPosition = null; return; }
|
||||
const src = nbPointer.src;
|
||||
if (path === src || path.startsWith(src + '/')) { dropTargetPath = null; dropPosition = null; return; }
|
||||
const rect = row!.getBoundingClientRect();
|
||||
const relY = (e.clientY - rect.top) / rect.height;
|
||||
const pos: 'above' | 'into' | 'below' = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
|
||||
if (pos === 'into' && src.substring(0, src.lastIndexOf('/')) === path) { dropTargetPath = null; dropPosition = null; return; }
|
||||
dropTargetPath = path;
|
||||
dropPosition = pos;
|
||||
}
|
||||
|
||||
function nbDragEnd() {
|
||||
if (!nbPointer) return;
|
||||
nbStopDragListeners();
|
||||
const src = nbPointer.src;
|
||||
const target = dropTargetPath;
|
||||
const pos = dropPosition;
|
||||
nbPointer = null;
|
||||
draggedNotebookPath = null;
|
||||
dropTargetPath = null;
|
||||
dropPosition = null;
|
||||
if (!target) return;
|
||||
if (target === '__root__') {
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (vaultRoot) nestNotebook(src, vaultRoot);
|
||||
} else if (pos === 'above' || pos === 'below') {
|
||||
handleNotebookReorder(src, target, pos);
|
||||
} else if (pos === 'into') {
|
||||
nestNotebook(src, target);
|
||||
}
|
||||
}
|
||||
|
||||
function findSiblingsOfPath(tree: NotebookEntry[], parentPath: string, vaultRoot: string): NotebookEntry[] {
|
||||
if (parentPath === vaultRoot) return tree;
|
||||
const target = norm(parentPath);
|
||||
if (target === norm(vaultRoot)) return tree;
|
||||
for (const nb of tree) {
|
||||
if (nb.path === parentPath) return nb.children;
|
||||
if (norm(nb.path) === target) return nb.children;
|
||||
if (nb.children.length > 0) {
|
||||
const found = findSiblingsOfPath(nb.children, parentPath, vaultRoot);
|
||||
if (found.length > 0) return found;
|
||||
@@ -300,41 +472,39 @@
|
||||
dropPosition = null;
|
||||
draggedNotebookPath = null;
|
||||
if (srcPath === targetPath) return;
|
||||
if (targetPath.startsWith(srcPath + '/')) return; // can't move into descendant
|
||||
if (isDescendant(targetPath, srcPath)) return; // can't move into descendant
|
||||
const vaultRoot = $appConfig?.active_vault;
|
||||
if (!vaultRoot) return;
|
||||
|
||||
const srcParent = srcPath.substring(0, srcPath.lastIndexOf('/'));
|
||||
const targetParent = targetPath.substring(0, targetPath.lastIndexOf('/'));
|
||||
const srcParent = parentOf(srcPath);
|
||||
const targetParent = parentOf(targetPath);
|
||||
let actualSrcPath = srcPath;
|
||||
|
||||
try {
|
||||
// Step 1: if different parents, move src into target's parent first
|
||||
if (srcParent !== targetParent) {
|
||||
const oldName = srcPath.split('/').pop() || '';
|
||||
const newPath = targetParent === vaultRoot
|
||||
? vaultRoot + '/' + oldName
|
||||
: targetParent + '/' + oldName;
|
||||
const oldName = baseOf(srcPath);
|
||||
const newPath = joinPath(targetParent, oldName);
|
||||
await moveNotebook(srcPath, targetParent);
|
||||
// Migrate paths in collapsedNotebooks
|
||||
$collapsedNotebooks = $collapsedNotebooks.map(p => {
|
||||
if (p === srcPath) return newPath;
|
||||
if (p.startsWith(srcPath + '/')) return newPath + p.slice(srcPath.length);
|
||||
if (isDescendant(p, srcPath)) return newPath + p.slice(srcPath.length);
|
||||
return p;
|
||||
});
|
||||
// Migrate paths in notebookOrder
|
||||
const migrated: Record<string, number> = {};
|
||||
for (const [p, v] of Object.entries($notebookOrder)) {
|
||||
if (p === srcPath) migrated[newPath] = v;
|
||||
else if (p.startsWith(srcPath + '/')) migrated[newPath + p.slice(srcPath.length)] = v;
|
||||
else if (isDescendant(p, srcPath)) migrated[newPath + p.slice(srcPath.length)] = v;
|
||||
else migrated[p] = v;
|
||||
}
|
||||
$notebookOrder = migrated;
|
||||
// Update active notebook/note
|
||||
if ($activeNotebook?.path === srcPath || $activeNotebook?.path.startsWith(srcPath + '/')) {
|
||||
if ($activeNotebook?.path === srcPath || isDescendant($activeNotebook?.path ?? '', srcPath)) {
|
||||
selectAllNotes();
|
||||
}
|
||||
if ($activeNotePath && $activeNotePath.startsWith(srcPath + '/')) {
|
||||
if ($activeNotePath && isDescendant($activeNotePath, srcPath)) {
|
||||
const newNotePath = newPath + $activeNotePath.slice(srcPath.length);
|
||||
$activeNotePath = newNotePath;
|
||||
$activeNote = await readNote(newNotePath);
|
||||
@@ -371,10 +541,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function startRename(nb: NotebookEntry) {
|
||||
async function startRename(nb: NotebookEntry) {
|
||||
contextMenu = null;
|
||||
editingNotebook = nb.path;
|
||||
editValue = nb.name;
|
||||
await tick();
|
||||
renameInput?.focus();
|
||||
renameInput?.select();
|
||||
}
|
||||
|
||||
async function handleSetIcon(nb: NotebookEntry) {
|
||||
@@ -463,7 +636,7 @@
|
||||
|
||||
<svelte:window onclick={handleWindowClick} />
|
||||
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile}>
|
||||
<aside class="sidebar" class:collapsed={$sidebarCollapsed} class:mobile={isMobile} class:nav-empty={!anyNavItem}>
|
||||
{#if !isMobile}
|
||||
<div class="sidebar-header">
|
||||
<button class="collapse-btn" onclick={() => ($sidebarCollapsed = !$sidebarCollapsed)} title="Toggle sidebar">
|
||||
@@ -475,19 +648,19 @@
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
{#if !$sidebarCollapsed}
|
||||
<button class="icon-btn" onclick={() => ($showSearch = true)} title={`Search (${modKey}+F)`}>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if !$sidebarCollapsed}
|
||||
{#if anyNavItem}
|
||||
<nav class="sidebar-nav">
|
||||
{#if $appConfig?.show_all_notes !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'all'}
|
||||
@@ -499,7 +672,9 @@
|
||||
</svg>
|
||||
<span>All Notes</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_quick_access !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'quickaccess'}
|
||||
@@ -510,7 +685,23 @@
|
||||
</svg>
|
||||
<span>Quick Access</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_tasks !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'tasks'}
|
||||
onclick={selectTasks}
|
||||
>
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M9 11l3 3L22 4" />
|
||||
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
|
||||
</svg>
|
||||
<span>Tasks</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_daily_notes !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'daily'}
|
||||
@@ -524,7 +715,9 @@
|
||||
</svg>
|
||||
<span>Daily Notes</span>
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if $appConfig?.show_trash !== false}
|
||||
<button
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'trash'}
|
||||
@@ -536,13 +729,17 @@
|
||||
</svg>
|
||||
<span>Trash</span>
|
||||
</button>
|
||||
{/if}
|
||||
</nav>
|
||||
{/if}
|
||||
|
||||
<div class="section">
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="section" ondblclick={startNewNotebookFromSidebar}>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="section-header"
|
||||
class:drop-target={dropTargetPath === '__root__'}
|
||||
data-nb-root
|
||||
ondragover={(e) => {
|
||||
if (draggedNotebookPath) {
|
||||
e.preventDefault();
|
||||
@@ -559,33 +756,39 @@
|
||||
}}
|
||||
>
|
||||
<span class="section-title">Notebooks</span>
|
||||
<button class="icon-btn-sm" onclick={() => {
|
||||
if (showNewNotebook) {
|
||||
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
|
||||
} else {
|
||||
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook) ? $activeNotebook : null;
|
||||
showNewNotebook = true;
|
||||
}
|
||||
}} title={$viewMode === 'notebook' && $activeNotebook ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="section-actions">
|
||||
{#if allParentPaths.length > 0}
|
||||
<button class="icon-btn-sm" onclick={(e) => { e.stopPropagation(); toggleAllCollapse(); }} title={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'} aria-label={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
{#if allCollapsed}
|
||||
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
|
||||
{:else}
|
||||
<path d="m7 20 5-5 5 5" /><path d="m7 4 5 5 5-5" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="icon-btn-sm" onclick={toggleNewNotebookInput} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showNewNotebook}
|
||||
<div class="new-notebook-input">
|
||||
{#if newNotebookParent}
|
||||
<span class="new-notebook-parent">{newNotebookParent.name}/</span>
|
||||
<button class="new-notebook-parent" title="Click to create a top-level notebook instead" onmousedown={(e) => { e.preventDefault(); newNotebookParent = null; }}>
|
||||
{newNotebookParent.name}/<span class="new-notebook-parent-x" aria-hidden="true">×</span>
|
||||
</button>
|
||||
{/if}
|
||||
<input
|
||||
bind:this={newNotebookInput}
|
||||
type="text"
|
||||
bind:value={newNotebookName}
|
||||
placeholder={newNotebookParent ? 'Sub-notebook name...' : 'Notebook name...'}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleCreateNotebook();
|
||||
if (e.key === 'Escape') { showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; }
|
||||
}}
|
||||
onkeydown={handleNewNotebookKeydown}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -657,6 +860,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="icon-btn" onclick={() => ($showInfo = true)} title="Info">
|
||||
@@ -676,7 +880,6 @@
|
||||
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</aside>
|
||||
|
||||
{#if contextMenu}
|
||||
@@ -743,6 +946,7 @@
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
bind:this={renameInput}
|
||||
bind:value={editValue}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Enter') handleRename(nb);
|
||||
@@ -759,11 +963,15 @@
|
||||
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
|
||||
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
|
||||
style="padding-left: {8 + depth * 16}px"
|
||||
draggable="true"
|
||||
data-nb-path={nb.path}
|
||||
onclick={() => selectNotebook(nb)}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'F2') {
|
||||
e.preventDefault();
|
||||
startRename(nb);
|
||||
}
|
||||
}}
|
||||
oncontextmenu={(e) => onContextMenu(e, nb)}
|
||||
ondragstart={(e) => { draggedNotebookPath = nb.path; e.dataTransfer!.setData('text/plain', nb.path); e.dataTransfer!.effectAllowed = 'move'; }}
|
||||
ondragend={() => { draggedNotebookPath = null; dropPosition = null; }}
|
||||
ondragover={(e) => {
|
||||
e.preventDefault();
|
||||
if (draggedNotebookPath) {
|
||||
@@ -792,8 +1000,14 @@
|
||||
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
|
||||
ondrop={(e) => {
|
||||
e.preventDefault();
|
||||
const pos = dropPosition;
|
||||
if (draggedNotebookPath) {
|
||||
// Recompute the drop position from the drop coordinates, not the dragover-set
|
||||
// state: dragleave over the row's child nodes (chevron/icon/label) clears
|
||||
// dropPosition before drop (esp. Windows WebView2), which mis-routed every drop
|
||||
// to "nest" and broke reorder + un-nest. (issue #97)
|
||||
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||
const relY = (e.clientY - rect.top) / rect.height;
|
||||
const pos = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
|
||||
if (pos === 'above' || pos === 'below') {
|
||||
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
|
||||
} else {
|
||||
@@ -806,7 +1020,7 @@
|
||||
>
|
||||
{#if hasChildren}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb.path, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb.path, e); }}>
|
||||
<span class="chevron" class:collapsed={isCollapsed} onclick={(e) => toggleCollapse(nb, e)} onkeydown={(e) => { if (e.key === 'Enter') toggleCollapse(nb, e); }}>
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
@@ -822,6 +1036,17 @@
|
||||
</svg>
|
||||
{/if}
|
||||
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
|
||||
{#if $notebookSortMode === 'manual'}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
|
||||
<span
|
||||
class="nb-drag-handle"
|
||||
title="Drag to reorder"
|
||||
onpointerdown={(e) => nbHandleDown(e, nb)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor"><circle cx="9" cy="5" r="1.6"/><circle cx="15" cy="5" r="1.6"/><circle cx="9" cy="12" r="1.6"/><circle cx="15" cy="12" r="1.6"/><circle cx="9" cy="19" r="1.6"/><circle cx="15" cy="19" r="1.6"/></svg>
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{#if hasChildren && !isCollapsed}
|
||||
@@ -849,6 +1074,29 @@
|
||||
max-width: 44px;
|
||||
}
|
||||
|
||||
/* Collapsed rail: stack the toolbar icons vertically - search under the expand chevron at
|
||||
the top, info + settings pinned to the bottom. */
|
||||
.sidebar.collapsed .sidebar-header {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 4px;
|
||||
padding: 8px 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-footer {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-top: auto;
|
||||
padding: 8px 4px;
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
.sidebar.collapsed .sidebar-footer-right {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -892,6 +1140,22 @@
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
/* With the top nav hidden, the header's border-bottom is the only divider needed -
|
||||
drop the section's border-top so there's no doubled line under the header. */
|
||||
.sidebar.nav-empty .section {
|
||||
border-top: none;
|
||||
}
|
||||
|
||||
/* Pin the Notebooks header so its collapse/expand-all button stays reachable while the tree
|
||||
scrolls; the section's border-top draws the divider above it, matching the Tags section. (issue #126) */
|
||||
.section > .section-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.tags-section {
|
||||
@@ -924,6 +1188,11 @@
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.section-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
.icon-btn, .icon-btn-sm {
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -969,11 +1238,30 @@
|
||||
}
|
||||
|
||||
.new-notebook-parent {
|
||||
padding: 4px 0 4px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: 0 2px 0 6px;
|
||||
padding: 2px 5px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: inherit;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-hover);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.new-notebook-parent:hover {
|
||||
color: var(--text-primary);
|
||||
background: var(--accent-light);
|
||||
}
|
||||
|
||||
.new-notebook-parent-x {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.new-notebook-input input {
|
||||
@@ -1088,6 +1376,27 @@
|
||||
margin-left: 2px;
|
||||
}
|
||||
|
||||
.nb-drag-handle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
padding: 2px;
|
||||
margin-left: 2px;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.45;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.nb-drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.nb-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { tags } from '$lib/stores/app';
|
||||
|
||||
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
|
||||
existing?: string[];
|
||||
placeholder?: string;
|
||||
onsubmit: (tag: string) => void;
|
||||
oncancel: () => void;
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
let selIndex = $state(-1);
|
||||
let inputEl = $state<HTMLInputElement>(null!);
|
||||
|
||||
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
|
||||
const suggestions = $derived.by(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
const taken = new Set(existing);
|
||||
const names = $tags
|
||||
.map(([name]) => name)
|
||||
.filter((name) => !taken.has(name) && (!q || name.toLowerCase().includes(q)));
|
||||
names.sort((a, b) => {
|
||||
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
|
||||
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
|
||||
if (ap !== bp) return ap - bp;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
return names.slice(0, 8);
|
||||
});
|
||||
|
||||
function submit(tag: string) {
|
||||
if (!tag.trim()) return;
|
||||
onsubmit(tag);
|
||||
query = '';
|
||||
selIndex = -1;
|
||||
}
|
||||
|
||||
function onKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'ArrowDown') {
|
||||
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex + 1) % suggestions.length; }
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex - 1 + suggestions.length) % suggestions.length; }
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
submit(selIndex >= 0 && selIndex < suggestions.length ? suggestions[selIndex] : query);
|
||||
} else if (e.key === 'Tab') {
|
||||
// Complete the input to the highlighted suggestion without committing.
|
||||
if (selIndex >= 0 && selIndex < suggestions.length) { e.preventDefault(); query = suggestions[selIndex]; selIndex = -1; }
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
if (selIndex >= 0) selIndex = -1; // first Escape just drops the highlight
|
||||
else oncancel();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => inputEl?.focus());
|
||||
</script>
|
||||
|
||||
<div class="tag-suggest">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
class="tag-suggest-input"
|
||||
{placeholder}
|
||||
bind:value={query}
|
||||
oninput={() => (selIndex = -1)}
|
||||
onkeydown={onKeydown}
|
||||
/>
|
||||
{#if suggestions.length}
|
||||
<div class="tag-suggest-list">
|
||||
{#each suggestions as s, i}
|
||||
<button
|
||||
type="button"
|
||||
class="tag-suggest-item"
|
||||
class:selected={i === selIndex}
|
||||
onmouseenter={() => (selIndex = i)}
|
||||
onmousedown={(e) => { e.preventDefault(); submit(s); }}
|
||||
>#{s}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tag-suggest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.tag-suggest-input {
|
||||
width: 100%;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
outline: none;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tag-suggest-input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tag-suggest-list {
|
||||
margin-top: 4px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
}
|
||||
.tag-suggest-item {
|
||||
text-align: left;
|
||||
padding: 5px 8px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tag-suggest-item:hover,
|
||||
.tag-suggest-item.selected {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,712 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { listen } from '@tauri-apps/api/event';
|
||||
import { getTasks } from '$lib/api';
|
||||
import { debounce } from '$lib/utils/debounce';
|
||||
import { tasksLayout, tasksHideCompleted, tasksOnlyFlagged, tasksSort, appConfig } from '$lib/stores/app';
|
||||
import { isAndroid } from '$lib/platform';
|
||||
import type { TaskItem, FileEvent } from '$lib/types';
|
||||
|
||||
let { onOpenTask = (_t: TaskItem) => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
|
||||
onOpenTask?: (t: TaskItem) => void;
|
||||
onToggleTask?: (t: TaskItem) => Promise<void>;
|
||||
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
|
||||
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
|
||||
} = $props();
|
||||
|
||||
let tasks = $state<TaskItem[]>([]);
|
||||
let loading = $state(true);
|
||||
let taskQuery = $state('');
|
||||
let unlisten: (() => void) | null = null;
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
tasks = await getTasks();
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks:', e);
|
||||
tasks = [];
|
||||
}
|
||||
loading = false;
|
||||
}
|
||||
|
||||
const debouncedLoad = debounce(load, 400);
|
||||
|
||||
onMount(() => {
|
||||
load();
|
||||
listen<FileEvent>('file-changed', () => debouncedLoad()).then((u) => (unlisten = u));
|
||||
return () => { unlisten?.(); };
|
||||
});
|
||||
|
||||
// Local YYYY-MM-DD (en-CA gives ISO-style date)
|
||||
const today = new Date().toLocaleDateString('en-CA');
|
||||
|
||||
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
|
||||
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
|
||||
// Free-text task filter: matches the task text or its note title (case-insensitive).
|
||||
function matchesQuery(t: TaskItem): boolean {
|
||||
const q = taskQuery.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return t.text.toLowerCase().includes(q) || t.note_title.toLowerCase().includes(q);
|
||||
}
|
||||
|
||||
const filtered = $derived.by(() => {
|
||||
let list = tasks.slice();
|
||||
if ($tasksHideCompleted) list = list.filter((t) => !t.completed);
|
||||
if ($tasksOnlyFlagged) list = list.filter((t) => !!t.due || !!t.priority);
|
||||
list = list.filter(matchesQuery);
|
||||
list.sort((a, b) => {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
|
||||
if ($tasksSort === 'priority') {
|
||||
const d = prank(a.priority) - prank(b.priority);
|
||||
if (d) return d;
|
||||
} else if ($tasksSort === 'note') {
|
||||
const d = a.note_title.localeCompare(b.note_title);
|
||||
if (d) return d;
|
||||
} else {
|
||||
const ad = a.due ?? '9999-99-99';
|
||||
const bd = b.due ?? '9999-99-99';
|
||||
if (ad !== bd) return ad < bd ? -1 : 1;
|
||||
}
|
||||
return a.text.localeCompare(b.text);
|
||||
});
|
||||
return list;
|
||||
});
|
||||
|
||||
const openCount = $derived(tasks.filter((t) => !t.completed).length);
|
||||
|
||||
function dueClass(due: string | null): string {
|
||||
if (!due) return '';
|
||||
if (due < today) return 'overdue';
|
||||
if (due === today) return 'duetoday';
|
||||
return '';
|
||||
}
|
||||
|
||||
async function toggle(t: TaskItem) {
|
||||
await onToggleTask(t);
|
||||
await load();
|
||||
}
|
||||
|
||||
let prioMenuFor = $state<string | null>(null);
|
||||
let dueMenuFor = $state<string | null>(null);
|
||||
const rowKey = (t: TaskItem) => t.note_path + ':' + t.line + ':' + t.raw_line;
|
||||
|
||||
async function choosePriority(t: TaskItem, p: string | null) {
|
||||
prioMenuFor = null;
|
||||
await onSetTaskPriority(t, p);
|
||||
await load();
|
||||
}
|
||||
|
||||
async function chooseDue(t: TaskItem, d: string | null) {
|
||||
dueMenuFor = null;
|
||||
await onSetTaskDue(t, d);
|
||||
await load();
|
||||
}
|
||||
|
||||
// ── Calendar ──
|
||||
// Layout persists per-vault (tasksLayout store -> VaultState). Android is always List.
|
||||
const viewLayout = $derived(isAndroid ? 'list' : $tasksLayout);
|
||||
const calNow = new Date();
|
||||
let calMonth = $state(calNow.getMonth());
|
||||
let calYear = $state(calNow.getFullYear());
|
||||
let selectedDate = $state(today);
|
||||
|
||||
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
|
||||
// 0 = Sunday, 1 = Monday (default). Day names + grid offset follow the setting.
|
||||
const weekStartsOn = $derived($appConfig?.week_start === 'sunday' ? 0 : 1);
|
||||
const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
|
||||
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
|
||||
|
||||
// Calendar tasks share the list's hide-completed filter (no sort).
|
||||
const calBase = $derived(tasks.filter((t) => (!$tasksHideCompleted || !t.completed) && (!$tasksOnlyFlagged || !!t.due || !!t.priority) && matchesQuery(t)));
|
||||
|
||||
const tasksByDate = $derived.by(() => {
|
||||
const m = new Map<string, TaskItem[]>();
|
||||
for (const t of calBase) {
|
||||
if (!t.due) continue;
|
||||
const arr = m.get(t.due);
|
||||
if (arr) arr.push(t);
|
||||
else m.set(t.due, [t]);
|
||||
}
|
||||
return m;
|
||||
});
|
||||
|
||||
const undatedTasks = $derived(calBase.filter((t) => !t.due));
|
||||
|
||||
function byPriority(a: TaskItem, b: TaskItem) {
|
||||
if (a.completed !== b.completed) return a.completed ? 1 : -1;
|
||||
const d = prank(a.priority) - prank(b.priority);
|
||||
return d || a.text.localeCompare(b.text);
|
||||
}
|
||||
|
||||
const selectedDayTasks = $derived(
|
||||
(selectedDate ? (tasksByDate.get(selectedDate) ?? []) : []).slice().sort(byPriority)
|
||||
);
|
||||
|
||||
function calDays() {
|
||||
const firstWeekday = (new Date(calYear, calMonth, 1).getDay() - weekStartsOn + 7) % 7;
|
||||
const lastDate = new Date(calYear, calMonth + 1, 0).getDate();
|
||||
const cells: { day: number; date: string; current: boolean }[] = [];
|
||||
for (let i = 0; i < firstWeekday; i++) cells.push({ day: 0, date: '', current: false });
|
||||
for (let d = 1; d <= lastDate; d++) {
|
||||
const date = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
||||
cells.push({ day: d, date, current: date === today });
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
|
||||
function calPrev() {
|
||||
if (calMonth === 0) { calMonth = 11; calYear -= 1; } else calMonth -= 1;
|
||||
}
|
||||
function calNext() {
|
||||
if (calMonth === 11) { calMonth = 0; calYear += 1; } else calMonth += 1;
|
||||
}
|
||||
function calToday() {
|
||||
const n = new Date();
|
||||
calMonth = n.getMonth();
|
||||
calYear = n.getFullYear();
|
||||
selectedDate = today;
|
||||
}
|
||||
|
||||
function agendaTitle(date: string): string {
|
||||
if (date === today) return 'Today';
|
||||
const [y, mo, d] = date.split('-').map(Number);
|
||||
return new Date(y, mo - 1, d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
// Drag a task (from the agenda or "No due date") onto a day to (re)schedule it.
|
||||
let draggedTask = $state<TaskItem | null>(null);
|
||||
let dragOverDate = $state<string | null>(null);
|
||||
|
||||
async function dropOnDay(date: string) {
|
||||
const t = draggedTask;
|
||||
draggedTask = null;
|
||||
dragOverDate = null;
|
||||
if (!t || t.due === date) return;
|
||||
await onSetTaskDue(t, date);
|
||||
await load();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tasks-view">
|
||||
{#if prioMenuFor || dueMenuFor}
|
||||
<!-- svelte-ignore a11y_consider_explicit_label -->
|
||||
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
|
||||
{/if}
|
||||
<div class="tasks-controls">
|
||||
<input class="tasks-filter" type="text" placeholder="Filter tasks…" bind:value={taskQuery} onkeydown={(e) => { if (e.key === 'Escape') taskQuery = ''; }} aria-label="Filter tasks by text" />
|
||||
<button class="tasks-ctl" class:active={$tasksHideCompleted} onclick={() => ($tasksHideCompleted = !$tasksHideCompleted)} title="Hide completed tasks">Hide done</button>
|
||||
<button class="tasks-ctl" class:active={$tasksOnlyFlagged} onclick={() => ($tasksOnlyFlagged = !$tasksOnlyFlagged)} title="Only show tasks with a due date or priority">Flagged</button>
|
||||
{#if !isAndroid}
|
||||
<div class="tasks-layout">
|
||||
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
|
||||
<button class="tasks-ctl" class:active={viewLayout === 'calendar'} onclick={() => ($tasksLayout = 'calendar')} title="Calendar view">Calendar</button>
|
||||
</div>
|
||||
{/if}
|
||||
{#if viewLayout === 'list'}
|
||||
<div class="tasks-sort">
|
||||
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
|
||||
<button class="tasks-ctl" class:active={$tasksSort === v} onclick={() => ($tasksSort = v as 'due' | 'priority' | 'note')}>{label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#snippet taskRow(t: TaskItem, dragMode: boolean)}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="task-row"
|
||||
class:done={t.completed}
|
||||
class:draggable={dragMode}
|
||||
draggable={dragMode}
|
||||
ondragstart={(e) => { if (!dragMode) return; draggedTask = t; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', rowKey(t)); } }}
|
||||
ondragend={() => { draggedTask = null; dragOverDate = null; }}
|
||||
>
|
||||
<button class="task-check" class:checked={t.completed} onclick={(e) => { e.stopPropagation(); toggle(t); }} title={t.completed ? 'Mark not done' : 'Mark done'} aria-label="toggle done"></button>
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<div class="task-main" onclick={() => onOpenTask(t)}>
|
||||
<div class="task-line">
|
||||
<span class="task-prio-wrap">
|
||||
<button
|
||||
class="task-prio prio-{t.priority ?? 'none'}"
|
||||
class:set={!!t.priority}
|
||||
title="Set priority"
|
||||
aria-label="Set priority"
|
||||
onclick={(e) => { e.stopPropagation(); dueMenuFor = null; prioMenuFor = prioMenuFor === rowKey(t) ? null : rowKey(t); }}
|
||||
>{t.priority ?? '!'}</button>
|
||||
{#if prioMenuFor === rowKey(t)}
|
||||
<div class="prio-menu" role="menu">
|
||||
<button class="prio-opt prio-high" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'high'); }}>High</button>
|
||||
<button class="prio-opt prio-med" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'med'); }}>Med</button>
|
||||
<button class="prio-opt prio-low" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'low'); }}>Low</button>
|
||||
<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); choosePriority(t, null); }}>None</button>
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
<span class="task-label">{t.text}</span>
|
||||
<span class="task-due-wrap">
|
||||
<button
|
||||
class="task-due {dueClass(t.due)}"
|
||||
class:set={!!t.due}
|
||||
title="Set due date"
|
||||
aria-label="Set due date"
|
||||
onclick={(e) => { e.stopPropagation(); prioMenuFor = null; dueMenuFor = dueMenuFor === rowKey(t) ? null : rowKey(t); }}
|
||||
>{t.due ?? 'due'}</button>
|
||||
{#if dueMenuFor === rowKey(t)}
|
||||
<div class="due-menu">
|
||||
<input
|
||||
class="due-input"
|
||||
type="date"
|
||||
value={t.due ?? ''}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onchange={(e) => { e.stopPropagation(); chooseDue(t, (e.target as HTMLInputElement).value || null); }}
|
||||
/>
|
||||
{#if t.due}<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); chooseDue(t, null); }}>Clear</button>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
<div class="task-note">{t.note_title}</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if loading}
|
||||
<div class="tasks-empty">Loading...</div>
|
||||
{:else if viewLayout === 'calendar'}
|
||||
<div class="tasks-cal-pane">
|
||||
<div class="cal">
|
||||
<div class="cal-nav">
|
||||
<button class="cal-nav-btn" onclick={calPrev} title="Previous month" aria-label="Previous month">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
|
||||
</button>
|
||||
<button class="cal-title" onclick={calToday} title="Jump to today">{calMonthNames[calMonth]} {calYear}</button>
|
||||
<button class="cal-nav-btn" onclick={calNext} title="Next month" aria-label="Next month">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="cal-grid">
|
||||
{#each calDayNames as name}<div class="cal-head">{name}</div>{/each}
|
||||
{#each calDays() as cell}
|
||||
{#if cell.day === 0}
|
||||
<div class="cal-cell empty"></div>
|
||||
{:else}
|
||||
{@const cnt = (tasksByDate.get(cell.date) ?? []).length}
|
||||
<button
|
||||
class="cal-cell"
|
||||
class:today={cell.current}
|
||||
class:active={cell.date === selectedDate}
|
||||
class:has-tasks={cnt > 0}
|
||||
class:drop-over={dragOverDate === cell.date}
|
||||
onclick={() => (selectedDate = cell.date)}
|
||||
ondragover={(e) => { if (draggedTask) { e.preventDefault(); dragOverDate = cell.date; } }}
|
||||
ondragleave={() => { if (dragOverDate === cell.date) dragOverDate = null; }}
|
||||
ondrop={(e) => { e.preventDefault(); dropOnDay(cell.date); }}
|
||||
>
|
||||
<span class="cal-day">{cell.day}</span>
|
||||
{#if cnt > 0}<span class="cal-count" class:overdue={cell.date < today}>{cnt}</span>{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<div class="cal-agenda">
|
||||
<div class="agenda-section">
|
||||
<div class="agenda-head" class:overdue={selectedDate < today}>{agendaTitle(selectedDate)}</div>
|
||||
{#if selectedDayTasks.length === 0}
|
||||
<div class="tasks-empty-sm">Nothing due.</div>
|
||||
{:else}
|
||||
{#each selectedDayTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{#if undatedTasks.length}
|
||||
<div class="agenda-section">
|
||||
<div class="agenda-head">No due date ({undatedTasks.length})</div>
|
||||
{#each undatedTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else if filtered.length === 0}
|
||||
<div class="tasks-empty">No tasks. Add <code>- [ ] something</code> to any note.{#if openCount === 0 && tasks.length}{' '}All done!{/if}</div>
|
||||
{:else}
|
||||
<div class="tasks-list">
|
||||
{#each filtered as t (rowKey(t))}{@render taskRow(t, false)}{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.tasks-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.tasks-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tasks-sort {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.tasks-layout {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
.tasks-ctl {
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.tasks-ctl.active {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.tasks-filter {
|
||||
width: 150px;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 5px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
}
|
||||
.tasks-filter::placeholder { color: var(--text-tertiary); }
|
||||
.tasks-filter:focus { outline: none; border-color: var(--accent); }
|
||||
.tasks-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.tasks-empty {
|
||||
padding: 24px 16px;
|
||||
color: var(--text-tertiary);
|
||||
font-size: 13px;
|
||||
text-align: center;
|
||||
}
|
||||
.task-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 7px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.task-row:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.task-row.draggable { cursor: grab; }
|
||||
.task-row.draggable:active { cursor: grabbing; }
|
||||
.task-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-top: 2px;
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-primary);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
}
|
||||
.task-check:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.task-check.checked {
|
||||
background: var(--accent);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.task-check.checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 3px;
|
||||
top: 0;
|
||||
width: 5px;
|
||||
height: 9px;
|
||||
border: solid #fff;
|
||||
border-width: 0 2px 2px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.task-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.task-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
font-size: 13px;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
.task-row.done .task-label {
|
||||
text-decoration: line-through;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.task-label {
|
||||
word-break: break-word;
|
||||
}
|
||||
.task-prio-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-prio {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
color: #fff;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.task-prio.prio-none {
|
||||
background: transparent;
|
||||
color: var(--text-tertiary);
|
||||
border: 1px dashed var(--border-color);
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.task-row:hover .task-prio.prio-none { opacity: 1; }
|
||||
.prio-high { background: #ef4444; }
|
||||
.prio-med { background: #f59e0b; }
|
||||
.prio-low { background: #64748b; }
|
||||
.prio-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 3px;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 84px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
padding: 3px;
|
||||
gap: 2px;
|
||||
}
|
||||
.prio-opt {
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
font-size: 12px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.prio-opt:hover { background: var(--bg-hover); }
|
||||
.prio-opt.prio-high { color: #ef4444; background: transparent; }
|
||||
.prio-opt.prio-med { color: #f59e0b; background: transparent; }
|
||||
.prio-opt.prio-low { color: #64748b; background: transparent; }
|
||||
.prio-opt.prio-high:hover,
|
||||
.prio-opt.prio-med:hover,
|
||||
.prio-opt.prio-low:hover { background: var(--bg-hover); }
|
||||
.prio-opt.prio-clear { color: var(--text-tertiary); }
|
||||
.prio-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: default;
|
||||
}
|
||||
.task-due-wrap {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.task-due {
|
||||
font-size: 11px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.task-due:not(.set) {
|
||||
background: transparent;
|
||||
border-style: dashed;
|
||||
color: var(--text-tertiary);
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.1s;
|
||||
}
|
||||
.task-row:hover .task-due:not(.set) { opacity: 1; }
|
||||
.task-due.overdue {
|
||||
color: #ef4444;
|
||||
border-color: #ef4444;
|
||||
}
|
||||
.task-due.duetoday {
|
||||
color: #f59e0b;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
.due-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 3px;
|
||||
z-index: 51;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
|
||||
padding: 6px;
|
||||
}
|
||||
.due-input {
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
padding: 3px 5px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
color-scheme: light dark;
|
||||
}
|
||||
.task-note {
|
||||
font-size: 11px;
|
||||
color: var(--text-tertiary);
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Calendar */
|
||||
.tasks-cal-pane {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.cal {
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.cal-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.cal-nav-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.cal-nav-btn:hover { background: var(--bg-hover); }
|
||||
.cal-title {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
padding: 3px 8px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.cal-title:hover { background: var(--bg-hover); }
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
.cal-head {
|
||||
text-align: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
color: var(--text-tertiary);
|
||||
padding: 2px 0;
|
||||
}
|
||||
.cal-cell {
|
||||
position: relative;
|
||||
aspect-ratio: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 1px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.cal-cell.empty { cursor: default; }
|
||||
.cal-cell:not(.empty):hover { background: var(--bg-hover); }
|
||||
.cal-cell.today { border-color: var(--accent); color: var(--accent); font-weight: 700; }
|
||||
.cal-cell.active { background: var(--accent); color: #fff; }
|
||||
.cal-cell.drop-over {
|
||||
border: 1px dashed var(--accent);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
.cal-count {
|
||||
min-width: 15px;
|
||||
height: 15px;
|
||||
padding: 0 4px;
|
||||
border-radius: 8px;
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-count.overdue { background: #ef4444; }
|
||||
.cal-cell.active .cal-count { background: rgba(255, 255, 255, 0.3); }
|
||||
.cal-agenda {
|
||||
flex: 1;
|
||||
}
|
||||
.agenda-section {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
.agenda-head {
|
||||
padding: 8px 12px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.agenda-head.overdue { color: #ef4444; }
|
||||
.tasks-empty-sm {
|
||||
padding: 6px 12px 10px;
|
||||
font-size: 12px;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab } from '$lib/stores/app';
|
||||
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, activeVaultConfig, syncState } from '$lib/stores/app';
|
||||
import { syncNow } from '$lib/api';
|
||||
|
||||
let { onNewNote = () => {}, onDailyNote = () => {} }: {
|
||||
onNewNote?: () => void;
|
||||
@@ -111,6 +112,13 @@
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
</button>
|
||||
{#if activeVaultConfig($appConfig)?.sync_provider === 'webdav'}
|
||||
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
|
||||
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
<button class="new-note-btn" onclick={onNewNote} title={`New Note (${modKey}+N)`}>
|
||||
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
|
||||
<path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { open } from '@tauri-apps/plugin-dialog';
|
||||
import { getCurrentWindow } from '@tauri-apps/api/window';
|
||||
import { openVault, getAppConfig } from '$lib/api';
|
||||
import { documentDir } from '@tauri-apps/api/path';
|
||||
import { openVault, removeVault, getAppConfig } from '$lib/api';
|
||||
import { appConfig, vaultReady } from '$lib/stores/app';
|
||||
import { isAndroid, isIOS, isMobile } from '$lib/platform';
|
||||
import type { VaultConfig } from '$lib/types';
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
|
||||
let loading = $state(false);
|
||||
let error = $state('');
|
||||
let vaultName = $state('HelixNotes');
|
||||
let hasPermission = $state(!isMobile);
|
||||
let hasPermission = $state(!isAndroid);
|
||||
let selectedLocation = $state('Documents');
|
||||
let customPath = $state('');
|
||||
let iosBasePath = $state('');
|
||||
|
||||
const storageLocations = [
|
||||
{ label: 'Documents', path: '/storage/emulated/0/Documents' },
|
||||
@@ -22,7 +24,7 @@
|
||||
{ label: 'Internal Storage', path: '/storage/emulated/0' },
|
||||
];
|
||||
|
||||
if (isMobile) {
|
||||
if (isAndroid) {
|
||||
checkPermission();
|
||||
setTimeout(checkPermission, 500);
|
||||
(window as any).__storagePermissionGranted = () => { hasPermission = true; };
|
||||
@@ -30,6 +32,10 @@
|
||||
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300);
|
||||
});
|
||||
}
|
||||
if (isIOS) {
|
||||
// iOS is sandboxed: the vault lives in the app's Documents dir (exposed via the Files app).
|
||||
documentDir().then((d) => { iosBasePath = d.replace(/\/+$/, ''); }).catch(() => {});
|
||||
}
|
||||
|
||||
function checkPermission() {
|
||||
try {
|
||||
@@ -56,10 +62,17 @@
|
||||
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents';
|
||||
}
|
||||
|
||||
let fullPath = $derived(isMobile ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}` : '');
|
||||
let fullPath = $derived(
|
||||
isIOS ? `${iosBasePath}/${vaultName.trim() || 'HelixNotes'}`
|
||||
: isAndroid ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}`
|
||||
: ''
|
||||
);
|
||||
|
||||
async function pickFolder() {
|
||||
if (isMobile) {
|
||||
if (isIOS) {
|
||||
const base = iosBasePath || (await documentDir()).replace(/\/+$/, '');
|
||||
await openSelectedVault(`${base}/${vaultName.trim() || 'HelixNotes'}`);
|
||||
} else if (isAndroid) {
|
||||
await openSelectedVault(fullPath);
|
||||
} else {
|
||||
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
|
||||
@@ -73,7 +86,7 @@
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
if (isMobile) {
|
||||
if (isAndroid) {
|
||||
try {
|
||||
const bridge = (window as any).Android;
|
||||
bridge?.prepareVaultDir?.(path);
|
||||
@@ -84,11 +97,25 @@
|
||||
$appConfig = config;
|
||||
$vaultReady = true;
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
const msg = String(e);
|
||||
if (isAndroid && /os error 13|permission denied/i.test(msg)) {
|
||||
error = 'Storage permission is required. Enable file access for HelixNotes in your system settings, then try again.';
|
||||
} else {
|
||||
error = msg;
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function forgetVault(path: string) {
|
||||
try {
|
||||
await removeVault(path);
|
||||
$appConfig = await getAppConfig();
|
||||
} catch (e) {
|
||||
error = String(e);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
@@ -118,15 +145,20 @@
|
||||
<h1>HelixNotes</h1>
|
||||
<p class="subtitle">Local markdown notes</p>
|
||||
{#if isMobile}
|
||||
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
||||
{#if isIOS}
|
||||
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone → HelixNotes".</p>
|
||||
{:else}
|
||||
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
|
||||
{/if}
|
||||
|
||||
{#if !hasPermission}
|
||||
{#if isAndroid && !hasPermission}
|
||||
<button class="btn-permission" onclick={requestPermission}>
|
||||
Grant File Access
|
||||
</button>
|
||||
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
|
||||
{/if}
|
||||
|
||||
{#if isAndroid}
|
||||
<div class="location-selector">
|
||||
<label class="location-label">Location</label>
|
||||
<div class="location-options">
|
||||
@@ -147,6 +179,7 @@
|
||||
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="vault-name-input">
|
||||
<label for="vault-name">Vault name</label>
|
||||
@@ -181,10 +214,18 @@
|
||||
<div class="recent">
|
||||
<span class="recent-label">Recent</span>
|
||||
{#each recentVaults as vault}
|
||||
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
|
||||
<span class="vault-name">{vault.name}</span>
|
||||
<span class="vault-path">{vault.path}</span>
|
||||
</button>
|
||||
<div class="vault-row">
|
||||
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}>
|
||||
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span>
|
||||
<span class="vault-path">{vault.path}</span>
|
||||
</button>
|
||||
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
|
||||
<svg width="12" height="12" viewBox="0 0 10 10">
|
||||
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -348,6 +389,24 @@
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.vault-item.current {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.vault-current-badge {
|
||||
display: inline-block;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
padding: 2px 7px;
|
||||
border-radius: 10px;
|
||||
margin-left: 8px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.vault-name {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
@@ -365,6 +424,48 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.vault-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 6px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.vault-row .vault-item {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.vault-remove {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
width: 36px;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s, background 0.15s, color 0.15s;
|
||||
}
|
||||
|
||||
.vault-row:hover .vault-remove,
|
||||
.vault-remove:focus-visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vault-remove:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vault-picker.mobile .vault-remove {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.vault-name-input {
|
||||
margin-bottom: 20px;
|
||||
text-align: left;
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Obsidian-compatible callouts.
|
||||
//
|
||||
// Callouts are written as blockquotes whose first line is `[!type]`:
|
||||
//
|
||||
// > [!warning] Optional title
|
||||
// > Body markdown...
|
||||
//
|
||||
// A trailing `+` / `-` on the type makes the callout foldable
|
||||
// (`+` = expanded, `-` = collapsed). Types are case-insensitive and have
|
||||
// aliases. Callouts round-trip to this exact syntax so notes stay portable
|
||||
// (no lock-in) and stay compatible with Obsidian.
|
||||
|
||||
// alias (lowercase) -> canonical group used for icon + colour
|
||||
const ALIASES: Record<string, string> = {
|
||||
note: 'note',
|
||||
abstract: 'abstract', summary: 'abstract', tldr: 'abstract',
|
||||
info: 'info',
|
||||
todo: 'todo',
|
||||
tip: 'tip', hint: 'tip', important: 'tip',
|
||||
success: 'success', check: 'success', done: 'success',
|
||||
question: 'question', help: 'question', faq: 'question',
|
||||
warning: 'warning', caution: 'warning', attention: 'warning',
|
||||
failure: 'failure', fail: 'failure', missing: 'failure',
|
||||
danger: 'danger', error: 'danger',
|
||||
bug: 'bug',
|
||||
example: 'example',
|
||||
quote: 'quote', cite: 'quote',
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a (possibly aliased / cased) type to its canonical styling group.
|
||||
* Unknown / user-defined types resolve to 'custom' (neutral accent + tag icon)
|
||||
* so a `[!decision]` reads as intentional rather than masquerading as a note.
|
||||
*/
|
||||
export function calloutGroup(type: string): string {
|
||||
return ALIASES[(type || '').trim().toLowerCase()] || 'custom';
|
||||
}
|
||||
|
||||
/** Default header label shown when no custom title is set (matches Obsidian). */
|
||||
export function calloutLabel(type: string): string {
|
||||
const t = (type || 'note').trim();
|
||||
return t ? t.charAt(0).toUpperCase() + t.slice(1) : 'Note';
|
||||
}
|
||||
|
||||
// Inner SVG markup per canonical group (24x24, stroke = currentColor).
|
||||
const ICONS: Record<string, string> = {
|
||||
note: '<path d="M12 20h9"/><path d="M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z"/>',
|
||||
abstract: '<rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M9 12h6"/><path d="M9 16h6"/>',
|
||||
info: '<circle cx="12" cy="12" r="10"/><line x1="12" y1="11" x2="12" y2="16"/><line x1="12" y1="8" x2="12.01" y2="8"/>',
|
||||
todo: '<circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/>',
|
||||
tip: '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.07-2.14-.22-4.05 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.15.43-2.29 1-3a2.5 2.5 0 0 0 2.5 2.5Z"/>',
|
||||
success: '<polyline points="20 6 9 17 4 12"/>',
|
||||
question: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
warning: '<path d="m21.73 18-8-14a2 2 0 0 0-3.46 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3Z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
failure: '<circle cx="12" cy="12" r="10"/><line x1="15" y1="9" x2="9" y2="15"/><line x1="9" y1="9" x2="15" y2="15"/>',
|
||||
danger: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
bug: '<rect x="8" y="6" width="8" height="14" rx="4"/><path d="m19 8-3 2"/><path d="m5 8 3 2"/><path d="m19 16-3-2"/><path d="m5 16 3-2"/><path d="M21 13h-5"/><path d="M3 13h5"/><path d="m15 4-1.5 2.5"/><path d="m9 4 1.5 2.5"/>',
|
||||
example: '<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>',
|
||||
quote: '<path d="M3 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2H4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 0-1 1.03V20c0 1 0 1 1 1z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.76-2.02-2-2h-4c-1.25 0-2 .75-2 1.97V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3c0 1 0 1 1 1z"/>',
|
||||
custom: '<path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/>',
|
||||
};
|
||||
|
||||
/** Full SVG icon string for a callout type, at the given pixel size. */
|
||||
export function calloutIcon(type: string, size = 18): string {
|
||||
const inner = ICONS[calloutGroup(type)] || ICONS.note;
|
||||
return `<svg width="${size}" height="${size}" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${inner}</svg>`;
|
||||
}
|
||||
|
||||
/** Ordered list of types offered in the type picker. */
|
||||
export const CALLOUT_MENU: { type: string; label: string }[] = [
|
||||
{ type: 'note', label: 'Note' },
|
||||
{ type: 'abstract', label: 'Abstract' },
|
||||
{ type: 'info', label: 'Info' },
|
||||
{ type: 'todo', label: 'Todo' },
|
||||
{ type: 'tip', label: 'Tip' },
|
||||
{ type: 'success', label: 'Success' },
|
||||
{ type: 'question', label: 'Question' },
|
||||
{ type: 'warning', label: 'Warning' },
|
||||
{ type: 'failure', label: 'Failure' },
|
||||
{ type: 'danger', label: 'Danger' },
|
||||
{ type: 'bug', label: 'Bug' },
|
||||
{ type: 'example', label: 'Example' },
|
||||
{ type: 'quote', label: 'Quote' },
|
||||
];
|
||||
|
||||
// First line of a callout: [!type], optional +/- fold marker, optional title.
|
||||
const CALLOUT_RE = /^\s*\[!([\w-]+)\]([-+]?)[ \t]*(.*)$/;
|
||||
|
||||
const BLOCK_SELECTOR =
|
||||
'p,ul,ol,blockquote,pre,h1,h2,h3,h4,h5,h6,table,hr,div[data-callout],div[data-secret-block],div[data-math-block],div[data-pdf-src],div[data-page-break]';
|
||||
|
||||
/**
|
||||
* Convert callout blockquotes produced by markdown-it into
|
||||
* `<div data-callout=...>` blocks that the Callout node parses. Operates in
|
||||
* place on a detached container element. Processes depth-first so nested
|
||||
* callouts (`> > [!todo]`) are converted before their parents.
|
||||
*/
|
||||
export function transformCalloutBlockquotes(root: Element): void {
|
||||
const walk = (el: Element) => {
|
||||
for (const child of Array.from(el.children)) walk(child);
|
||||
if (el.tagName === 'BLOCKQUOTE') convertBlockquote(el);
|
||||
};
|
||||
walk(root);
|
||||
}
|
||||
|
||||
function convertBlockquote(bq: Element): void {
|
||||
const first = bq.firstElementChild;
|
||||
if (!first || first.tagName !== 'P') return;
|
||||
|
||||
// markdown-it merges the title line and any immediately-following body lines
|
||||
// into one paragraph (separated by "\n"). Split off the first line.
|
||||
const innerHtml = (first as HTMLElement).innerHTML;
|
||||
const nlIdx = innerHtml.indexOf('\n');
|
||||
const firstLineHtml = nlIdx === -1 ? innerHtml : innerHtml.slice(0, nlIdx);
|
||||
const restHtml = nlIdx === -1 ? '' : innerHtml.slice(nlIdx + 1);
|
||||
|
||||
const probe = document.createElement('div');
|
||||
probe.innerHTML = firstLineHtml;
|
||||
const m = (probe.textContent || '').match(CALLOUT_RE);
|
||||
if (!m) return;
|
||||
|
||||
const type = m[1].toLowerCase();
|
||||
const foldChar = m[2];
|
||||
const title = (m[3] || '').trim();
|
||||
const foldable = foldChar === '+' || foldChar === '-';
|
||||
const folded = foldChar === '-';
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('data-callout', type);
|
||||
div.setAttribute('data-callout-foldable', foldable ? 'true' : 'false');
|
||||
div.setAttribute('data-callout-folded', folded ? 'true' : 'false');
|
||||
if (title) div.setAttribute('data-callout-title', title);
|
||||
|
||||
if (restHtml.trim()) {
|
||||
(first as HTMLElement).innerHTML = restHtml;
|
||||
div.appendChild(first);
|
||||
} else {
|
||||
first.remove();
|
||||
}
|
||||
while (bq.firstChild) div.appendChild(bq.firstChild);
|
||||
|
||||
// content is `block+`; guarantee at least one block.
|
||||
if (!div.querySelector(BLOCK_SELECTOR)) div.appendChild(document.createElement('p'));
|
||||
|
||||
bq.replaceWith(div);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize a Callout ProseMirror node back to `> [!type] ...` markdown.
|
||||
* `serializeChild` serializes a child block (the editor's own serializer),
|
||||
* which makes nesting fall out automatically (each `>` line gains another `>`).
|
||||
*/
|
||||
export function serializeCallout(node: any, serializeChild: (child: any) => string): string {
|
||||
const type = node.attrs.type || 'note';
|
||||
const foldable = !!node.attrs.foldable;
|
||||
const folded = !!node.attrs.folded;
|
||||
const title = (node.attrs.title || '').trim();
|
||||
const suffix = foldable ? (folded ? '-' : '+') : '';
|
||||
const header = `[!${type}]${suffix}${title ? ' ' + title : ''}`;
|
||||
|
||||
const parts: string[] = [];
|
||||
node.forEach((child: any) => parts.push(serializeChild(child).replace(/\n+$/, '')));
|
||||
while (parts.length && parts[parts.length - 1] === '') parts.pop();
|
||||
|
||||
const out = [`> ${header}`];
|
||||
const inner = parts.join('\n');
|
||||
if (inner.length) {
|
||||
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
|
||||
}
|
||||
return out.join('\n') + '\n';
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Extension } from '@tiptap/core';
|
||||
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
|
||||
import { getSelectionPair } from '../selectionPairs';
|
||||
|
||||
// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection
|
||||
// without flattening selected inline marks or content to plain text.
|
||||
export const WrapSelectedText = Extension.create({
|
||||
name: 'wrapSelectedText',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey('wrapSelectedText'),
|
||||
props: {
|
||||
// Intercept only supported single-character text input over a range.
|
||||
// Everything else falls back to ProseMirror's normal input handling.
|
||||
handleTextInput(view, from, to, text) {
|
||||
const close = getSelectionPair(text);
|
||||
if (!close || from === to) return false;
|
||||
|
||||
const tr = view.state.tr.insertText(close, to).insertText(text, from);
|
||||
tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
|
||||
view.dispatch(tr);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
// Shared source of truth for editor behaviors that wrap an active selection.
|
||||
// Keep this small: only characters users reasonably expect to form pairs.
|
||||
const selectionPairs: Record<string, string> = {
|
||||
'(': ')',
|
||||
'{': '}',
|
||||
'[': ']',
|
||||
"'": "'",
|
||||
'"': '"',
|
||||
'`': '`',
|
||||
};
|
||||
|
||||
// Returns the closing character for a typed wrapper key, or null to pass through.
|
||||
export function getSelectionPair(key: string) {
|
||||
return selectionPairs[key] ?? null;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { getSelectionPair } from '../selectionPairs';
|
||||
|
||||
// Pure source-mode helper: computes the wrapped textarea value and preserved
|
||||
// inner selection so Svelte event handlers stay as glue code.
|
||||
export function wrapTextareaSelection(value: string, start: number, end: number, key: string) {
|
||||
const close = getSelectionPair(key);
|
||||
if (!close || start === end) return null;
|
||||
|
||||
return {
|
||||
value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end),
|
||||
selectionStart: start + key.length,
|
||||
selectionEnd: end + key.length,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
// A single key combination. `mod` means Ctrl on Windows/Linux and Cmd on macOS.
|
||||
// `code` is a KeyboardEvent.code value (layout-independent), e.g. "KeyN",
|
||||
// "Backslash", "ArrowLeft", "F11".
|
||||
export interface Binding {
|
||||
mod?: boolean;
|
||||
shift?: boolean;
|
||||
alt?: boolean;
|
||||
code: string;
|
||||
}
|
||||
|
||||
export interface ActionDef {
|
||||
id: string;
|
||||
label: string;
|
||||
group: "General" | "Interface" | "Navigation";
|
||||
default: Binding;
|
||||
}
|
||||
|
||||
// Every customizable action and its factory-default binding. These mirror the
|
||||
// handlers in AppLayout.handleKeydown and the rows in InfoPanel's Shortcuts tab.
|
||||
export const ACTIONS: ActionDef[] = [
|
||||
{ id: "new-note", label: "New note", group: "General", default: { mod: true, code: "KeyN" } },
|
||||
{ id: "save", label: "Save", group: "General", default: { mod: true, code: "KeyS" } },
|
||||
{ id: "quick-open", label: "Quick open", group: "General", default: { mod: true, code: "KeyP" } },
|
||||
{ id: "find-in-note", label: "Find in note", group: "General", default: { mod: true, code: "KeyF" } },
|
||||
{ id: "search-vault", label: "Search vault", group: "General", default: { mod: true, shift: true, code: "KeyF" } },
|
||||
{ id: "open-new-window", label: "Open note in new window", group: "General", default: { mod: true, shift: true, code: "KeyW" } },
|
||||
|
||||
{ id: "toggle-sidebar", label: "Toggle sidebar", group: "Interface", default: { mod: true, code: "Backslash" } },
|
||||
{ id: "toggle-notelist", label: "Toggle notes list", group: "Interface", default: { mod: true, shift: true, code: "Backslash" } },
|
||||
{ id: "toggle-theme", label: "Toggle dark / light theme", group: "Interface", default: { mod: true, shift: true, code: "KeyN" } },
|
||||
{ id: "toggle-focus", label: "Toggle focus mode", group: "Interface", default: { mod: true, shift: true, code: "KeyE" } },
|
||||
{ id: "toggle-readonly", label: "Toggle read-only", group: "Interface", default: { mod: true, shift: true, code: "KeyR" } },
|
||||
{ id: "toggle-source", label: "Toggle source / WYSIWYG", group: "Interface", default: { mod: true, shift: true, code: "KeyM" } },
|
||||
{ id: "fullscreen", label: "Toggle fullscreen", group: "Interface", default: { code: "F11" } },
|
||||
|
||||
{ id: "nav-back", label: "Go back", group: "Navigation", default: { alt: true, code: "ArrowLeft" } },
|
||||
{ id: "nav-forward", label: "Go forward", group: "Navigation", default: { alt: true, code: "ArrowRight" } },
|
||||
];
|
||||
|
||||
export const DEFAULT_BINDINGS: Record<string, Binding> = Object.fromEntries(
|
||||
ACTIONS.map((a) => [a.id, a.default]),
|
||||
);
|
||||
|
||||
const STORAGE_KEY = "helix-keybindings";
|
||||
|
||||
function loadBindings(): Record<string, Binding> {
|
||||
const merged: Record<string, Binding> = { ...DEFAULT_BINDINGS };
|
||||
try {
|
||||
const raw = typeof localStorage !== "undefined" && localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
const stored = JSON.parse(raw) as Record<string, Binding>;
|
||||
for (const id of Object.keys(merged)) {
|
||||
if (stored[id]) merged[id] = stored[id];
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Corrupt storage - fall back to defaults.
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
export const keybindings = writable<Record<string, Binding>>(loadBindings());
|
||||
|
||||
keybindings.subscribe((map) => {
|
||||
try {
|
||||
if (typeof localStorage !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map));
|
||||
}
|
||||
} catch {
|
||||
// Ignore quota / unavailable storage.
|
||||
}
|
||||
});
|
||||
|
||||
export function setBinding(id: string, binding: Binding) {
|
||||
keybindings.update((m) => ({ ...m, [id]: binding }));
|
||||
}
|
||||
|
||||
export function resetBinding(id: string) {
|
||||
keybindings.update((m) => ({ ...m, [id]: DEFAULT_BINDINGS[id] }));
|
||||
}
|
||||
|
||||
// True when the keyboard event matches the given binding exactly. Modifier
|
||||
// state must match precisely so e.g. Mod+Shift+F never also triggers Mod+F.
|
||||
export function matchBinding(e: KeyboardEvent, b: Binding | undefined): boolean {
|
||||
if (!b) return false;
|
||||
const mod = e.ctrlKey || e.metaKey;
|
||||
return (
|
||||
!!b.mod === mod &&
|
||||
!!b.shift === e.shiftKey &&
|
||||
!!b.alt === e.altKey &&
|
||||
e.code === b.code
|
||||
);
|
||||
}
|
||||
|
||||
// Returns the id of the first action whose binding matches the event, or null.
|
||||
export function matchAction(e: KeyboardEvent, map: Record<string, Binding>): string | null {
|
||||
for (const id of Object.keys(map)) {
|
||||
if (matchBinding(e, map[id])) return id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build a Binding from a captured keydown event. Returns null for events that
|
||||
// are only modifier keys (no real key pressed yet).
|
||||
const MODIFIER_CODES = new Set([
|
||||
"ControlLeft", "ControlRight", "MetaLeft", "MetaRight",
|
||||
"ShiftLeft", "ShiftRight", "AltLeft", "AltRight",
|
||||
]);
|
||||
|
||||
export function bindingFromEvent(e: KeyboardEvent): Binding | null {
|
||||
if (MODIFIER_CODES.has(e.code) || !e.code) return null;
|
||||
return {
|
||||
mod: e.ctrlKey || e.metaKey,
|
||||
shift: e.shiftKey,
|
||||
alt: e.altKey,
|
||||
code: e.code,
|
||||
};
|
||||
}
|
||||
|
||||
export function bindingsEqual(a: Binding, b: Binding): boolean {
|
||||
return !!a.mod === !!b.mod && !!a.shift === !!b.shift && !!a.alt === !!b.alt && a.code === b.code;
|
||||
}
|
||||
|
||||
const isMac = typeof navigator !== "undefined" && navigator.platform.startsWith("Mac");
|
||||
|
||||
const CODE_LABELS: Record<string, string> = {
|
||||
Backslash: "\\",
|
||||
BracketLeft: "[",
|
||||
BracketRight: "]",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
Semicolon: ";",
|
||||
Quote: "'",
|
||||
Backquote: "`",
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Space: "Space",
|
||||
Enter: "Enter",
|
||||
Tab: "Tab",
|
||||
ArrowLeft: "←",
|
||||
ArrowRight: "→",
|
||||
ArrowUp: "↑",
|
||||
ArrowDown: "↓",
|
||||
};
|
||||
|
||||
function codeToLabel(code: string): string {
|
||||
if (code.startsWith("Key")) return code.slice(3);
|
||||
if (code.startsWith("Digit")) return code.slice(5);
|
||||
if (code.startsWith("Numpad")) return code.slice(6);
|
||||
return CODE_LABELS[code] ?? code;
|
||||
}
|
||||
|
||||
// Ordered list of key labels for rendering, e.g. ["Ctrl", "Shift", "N"].
|
||||
export function bindingToKeys(b: Binding): string[] {
|
||||
const keys: string[] = [];
|
||||
if (b.mod) keys.push(isMac ? "⌘" : "Ctrl");
|
||||
if (b.alt) keys.push(isMac ? "⌥" : "Alt");
|
||||
if (b.shift) keys.push(isMac ? "⇧" : "Shift");
|
||||
keys.push(codeToLabel(b.code));
|
||||
return keys;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Platform is the compile-time build target, injected by the backend into
|
||||
// window.__HELIX_PLATFORM__ before app scripts run (see src-tauri/src/lib.rs). The UA is only a
|
||||
// fallback when that global is absent (SSR/prerender, plain-browser dev): some desktop WebKitGTK
|
||||
// builds report a mobile-looking user-agent. (#63)
|
||||
type HelixPlatform = { mobile: boolean; android: boolean; ios: boolean };
|
||||
|
||||
const injected: HelixPlatform | undefined =
|
||||
typeof window !== 'undefined'
|
||||
? (window as unknown as { __HELIX_PLATFORM__?: HelixPlatform }).__HELIX_PLATFORM__
|
||||
: undefined;
|
||||
|
||||
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
|
||||
|
||||
export const isAndroid = injected ? injected.android : /android/i.test(ua);
|
||||
export const isIOS = injected ? injected.ios : /iphone|ipad|ipod/i.test(ua);
|
||||
export const isMobile = injected ? injected.mobile : isAndroid || isIOS;
|
||||
|
||||
// Themes that use the dark color scheme. Used by applyTheme() to toggle the
|
||||
// `dark` class on the root element. Keep this in sync when adding new themes.
|
||||
export const darkThemes = [
|
||||
'dark',
|
||||
'solarized-dark',
|
||||
'catppuccin',
|
||||
'nord',
|
||||
'tokyo-night',
|
||||
'github-dark',
|
||||
'dracula',
|
||||
'blueberry',
|
||||
'forest-green',
|
||||
'gruvbox',
|
||||
'midnight-tide',
|
||||
'cherry-blossom',
|
||||
'synthwave',
|
||||
'ember',
|
||||
'moonlit',
|
||||
'dark-coffee',
|
||||
'crimson',
|
||||
'material-dark',
|
||||
'monokai',
|
||||
'rose-pine',
|
||||
'everforest',
|
||||
'horizon',
|
||||
'cyberpunk',
|
||||
'black',
|
||||
'one-dark',
|
||||
];
|
||||
|
||||
export function isDarkTheme(theme: string): boolean {
|
||||
return darkThemes.includes(theme) || (theme === 'system' && typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
// Ambient type shims for markdown-it plugins that ship without their own TypeScript
|
||||
// declarations. Type-only; no runtime effect.
|
||||
declare module 'markdown-it-mark';
|
||||
declare module 'markdown-it-sub';
|
||||
declare module 'markdown-it-sup';
|
||||
@@ -1,9 +1,12 @@
|
||||
import { writable, derived, get } from "svelte/store";
|
||||
import { isMobile } from "$lib/platform";
|
||||
import type {
|
||||
AppConfig,
|
||||
CustomTheme,
|
||||
NoteEntry,
|
||||
NoteContent,
|
||||
NotebookEntry,
|
||||
VaultConfig,
|
||||
VaultState,
|
||||
ViewMode,
|
||||
SortMode,
|
||||
@@ -11,14 +14,28 @@ import type {
|
||||
|
||||
// App state
|
||||
export const appConfig = writable<AppConfig | null>(null);
|
||||
|
||||
// The active vault's config entry (where per-vault WebDAV sync settings live).
|
||||
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
|
||||
if (!c?.active_vault) return null;
|
||||
return c.vaults.find((v) => v.path === c.active_vault) ?? null;
|
||||
}
|
||||
export const vaultReady = writable(false);
|
||||
|
||||
// UI state
|
||||
export const viewMode = writable<ViewMode>("all");
|
||||
export const sortMode = writable<SortMode>("modified");
|
||||
// Group the notes list under relative-date headers (All Notes, date sorts only). Persisted per vault.
|
||||
export const groupNotesByDate = writable<boolean>(false);
|
||||
export const tasksLayout = writable<"list" | "calendar">("list");
|
||||
export const tasksHideCompleted = writable<boolean>(true);
|
||||
export const tasksOnlyFlagged = writable<boolean>(false);
|
||||
export const tasksSort = writable<"due" | "priority" | "note">("due");
|
||||
export const sidebarCollapsed = writable(false);
|
||||
export const notelistCollapsed = writable(false);
|
||||
export const sidebarWidth = writable(220);
|
||||
export const notelistWidth = writable(280);
|
||||
export const outlineWidth = writable(220);
|
||||
export const searchQuery = writable("");
|
||||
export const showCommandPalette = writable(false);
|
||||
export const showSearch = writable(false);
|
||||
@@ -35,6 +52,7 @@ export const notebookSortMode = writable<"alphabetical" | "manual">(
|
||||
);
|
||||
// Map from notebook absolute path → ordinal position (lower = earlier). Only consulted when notebookSortMode === 'manual'.
|
||||
export const notebookOrder = writable<Record<string, number>>({});
|
||||
export const noteOrder = writable<Record<string, number>>({});
|
||||
|
||||
// Data
|
||||
export const notebooks = writable<NotebookEntry[]>([]);
|
||||
@@ -66,6 +84,14 @@ export const readOnly = writable(false);
|
||||
|
||||
// Theme
|
||||
export const theme = writable<string>("system");
|
||||
export const customThemes = derived(appConfig, ($c): CustomTheme[] => $c?.custom_themes ?? []);
|
||||
|
||||
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
|
||||
// whoever triggered it (manual button, settings, interval, on-change).
|
||||
export const syncState = writable<{ running: boolean; error: string | null }>({
|
||||
running: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Update state
|
||||
export const updateAvailable = writable<{
|
||||
@@ -74,8 +100,19 @@ export const updateAvailable = writable<{
|
||||
} | null>(null);
|
||||
export const updateObj = writable<any>(null);
|
||||
export const installType = writable<string>("native");
|
||||
// True only on the Android/iOS build. Reconfirmed from the backend at startup (see +layout). (#63)
|
||||
export const platformIsMobile = writable<boolean>(isMobile);
|
||||
export const androidApkUrl = writable<string | null>(null);
|
||||
|
||||
// Install types that handle their own updates (in-app auto-updater, or a
|
||||
// package-manager notice for deb/aur). Anything else - e.g. a distro repo build
|
||||
// that sets the HELIXNOTES_INSTALL_TYPE build flag (Solus, etc.) - is "managed":
|
||||
// the app does no update check and shows no update UI at all.
|
||||
const SELF_UPDATING_INSTALL_TYPES = ["macos", "windows", "deb", "aur", "appimage", "native", "android"];
|
||||
export function isManagedInstall(type: string): boolean {
|
||||
return !SELF_UPDATING_INSTALL_TYPES.includes(type);
|
||||
}
|
||||
|
||||
export async function checkForUpdate() {
|
||||
try {
|
||||
const { check } = await import("@tauri-apps/plugin-updater");
|
||||
@@ -151,16 +188,30 @@ export const canGoForward = derived(navHistory, $h => $h.index < $h.stack.length
|
||||
|
||||
// Derived
|
||||
export const sortedNotes = derived(
|
||||
[notes, sortMode, viewMode],
|
||||
([$notes, $sortMode, $viewMode]) => {
|
||||
[notes, sortMode, viewMode, noteOrder],
|
||||
([$notes, $sortMode, $viewMode, $noteOrder]) => {
|
||||
// Quick Access preserves stored order
|
||||
if ($viewMode === "quickaccess") return $notes;
|
||||
const customSortable =
|
||||
$viewMode === "all" || $viewMode === "notebook" || $viewMode === "tag";
|
||||
|
||||
const pinned = $notes.filter((n) => n.meta.pinned);
|
||||
const unpinned = $notes.filter((n) => !n.meta.pinned);
|
||||
|
||||
const sortFn = (a: NoteEntry, b: NoteEntry) => {
|
||||
switch ($sortMode) {
|
||||
case "custom": {
|
||||
if (!customSortable) {
|
||||
return (
|
||||
new Date(b.meta.modified).getTime() -
|
||||
new Date(a.meta.modified).getTime()
|
||||
);
|
||||
}
|
||||
const oa = $noteOrder[a.path] ?? Number.MAX_SAFE_INTEGER;
|
||||
const ob = $noteOrder[b.path] ?? Number.MAX_SAFE_INTEGER;
|
||||
if (oa !== ob) return oa - ob;
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
}
|
||||
case "title":
|
||||
return a.meta.title.localeCompare(b.meta.title);
|
||||
case "created":
|
||||
@@ -186,28 +237,37 @@ export const vaultState = derived(
|
||||
activeNotePath,
|
||||
sidebarWidth,
|
||||
notelistWidth,
|
||||
outlineWidth,
|
||||
sidebarCollapsed,
|
||||
notelistCollapsed,
|
||||
collapsedNotebooks,
|
||||
notebookSortMode,
|
||||
notebookOrder,
|
||||
noteOrder,
|
||||
],
|
||||
([
|
||||
$activeNotePath,
|
||||
$sidebarWidth,
|
||||
$notelistWidth,
|
||||
$outlineWidth,
|
||||
$sidebarCollapsed,
|
||||
$notelistCollapsed,
|
||||
$collapsedNotebooks,
|
||||
$notebookSortMode,
|
||||
$notebookOrder,
|
||||
$noteOrder,
|
||||
]) => {
|
||||
return {
|
||||
last_open_note: $activeNotePath,
|
||||
sidebar_width: $sidebarWidth,
|
||||
notelist_width: $notelistWidth,
|
||||
outline_width: $outlineWidth,
|
||||
sidebar_collapsed: $sidebarCollapsed,
|
||||
notelist_collapsed: $notelistCollapsed,
|
||||
collapsed_notebooks: $collapsedNotebooks,
|
||||
notebook_sort_mode: $notebookSortMode,
|
||||
notebook_order: $notebookOrder,
|
||||
note_order: $noteOrder,
|
||||
} satisfies VaultState;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -44,6 +44,34 @@ export interface NoteContent {
|
||||
export interface VaultConfig {
|
||||
path: string;
|
||||
name: string;
|
||||
// Per-vault WebDAV sync (moved off the global AppConfig).
|
||||
sync_provider?: string | null;
|
||||
webdav_url?: string | null;
|
||||
webdav_username?: string | null;
|
||||
webdav_password?: string | null;
|
||||
sync_on_open?: boolean;
|
||||
sync_on_change?: boolean;
|
||||
sync_interval_minutes?: number;
|
||||
last_sync_time?: string | null;
|
||||
}
|
||||
|
||||
export interface CustomThemeColors {
|
||||
bg_primary: string;
|
||||
bg_secondary: string;
|
||||
bg_tertiary: string;
|
||||
bg_hover: string;
|
||||
bg_active: string;
|
||||
bg_editor: string;
|
||||
text_primary: string;
|
||||
text_secondary: string;
|
||||
border_color: string;
|
||||
}
|
||||
|
||||
export interface CustomTheme {
|
||||
id: string;
|
||||
name: string;
|
||||
is_dark: boolean;
|
||||
colors: CustomThemeColors;
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -54,14 +82,26 @@ export interface AppConfig {
|
||||
font_size: number | null;
|
||||
font_family: string | null;
|
||||
line_height: number | null;
|
||||
ui_scale: number | null;
|
||||
content_width: number | null;
|
||||
compact_notes: boolean;
|
||||
show_note_dates: boolean;
|
||||
time_format: string;
|
||||
week_start: string;
|
||||
daily_title_format: string;
|
||||
gpu_acceleration: boolean;
|
||||
autostart: boolean;
|
||||
pdf_preview: boolean;
|
||||
pdf_height: number;
|
||||
title_mode: string;
|
||||
hide_title_in_body: boolean;
|
||||
show_line_numbers: boolean;
|
||||
show_link_arrows: boolean;
|
||||
show_all_notes: boolean;
|
||||
show_quick_access: boolean;
|
||||
show_tasks: boolean;
|
||||
show_daily_notes: boolean;
|
||||
show_trash: boolean;
|
||||
backup_enabled: boolean;
|
||||
backup_frequency: string;
|
||||
backup_max_count: number;
|
||||
@@ -73,21 +113,47 @@ export interface AppConfig {
|
||||
ai_api_key: string | null;
|
||||
openai_api_key: string | null;
|
||||
ollama_base_url: string | null;
|
||||
ollama_api_key: string | null;
|
||||
openai_compatible_base_url: string | null;
|
||||
openai_compatible_api_key: string | null;
|
||||
ai_model: string;
|
||||
ai_writing_style: string | null;
|
||||
default_view_mode: boolean;
|
||||
show_tray_icon: boolean;
|
||||
close_to_tray: boolean;
|
||||
enable_wiki_links: boolean;
|
||||
restore_last_session: boolean;
|
||||
sync_provider: string | null;
|
||||
webdav_url: string | null;
|
||||
webdav_username: string | null;
|
||||
webdav_password: string | null;
|
||||
sync_on_open: boolean;
|
||||
sync_on_change: boolean;
|
||||
sync_interval_minutes: number;
|
||||
last_sync_time: string | null;
|
||||
custom_themes: CustomTheme[];
|
||||
}
|
||||
|
||||
export interface VaultState {
|
||||
last_open_note: string | null;
|
||||
sidebar_width: number;
|
||||
notelist_width: number;
|
||||
outline_width?: number;
|
||||
sidebar_collapsed: boolean;
|
||||
notelist_collapsed: boolean;
|
||||
collapsed_notebooks: string[];
|
||||
notebook_sort_mode?: string;
|
||||
notebook_order?: Record<string, number>;
|
||||
note_order?: Record<string, number>;
|
||||
sort_mode?: string;
|
||||
group_notes_by_date?: boolean;
|
||||
last_view_mode?: string;
|
||||
last_notebook?: string | null;
|
||||
last_tag?: string | null;
|
||||
tasks_layout?: string;
|
||||
tasks_hide_completed?: boolean;
|
||||
tasks_only_flagged?: boolean;
|
||||
tasks_sort?: string;
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
@@ -138,7 +204,7 @@ export interface NoteTitleEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export type SortMode = "modified" | "title" | "created";
|
||||
export type SortMode = "modified" | "title" | "created" | "custom";
|
||||
export type ViewMode =
|
||||
| "all"
|
||||
| "notebook"
|
||||
@@ -146,4 +212,16 @@ export type ViewMode =
|
||||
| "trash"
|
||||
| "search"
|
||||
| "quickaccess"
|
||||
| "daily";
|
||||
| "daily"
|
||||
| "tasks";
|
||||
|
||||
export interface TaskItem {
|
||||
note_path: string;
|
||||
note_title: string;
|
||||
line: number;
|
||||
raw_line: string;
|
||||
text: string;
|
||||
completed: boolean;
|
||||
due: string | null;
|
||||
priority: string | null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
const SECRET_VERSION = 2;
|
||||
const SECRET_CIPHER = 'AES-256-GCM';
|
||||
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
|
||||
const SECRET_ITERATIONS = 600_000;
|
||||
const SALT_BYTES = 16;
|
||||
const NONCE_BYTES = 12;
|
||||
|
||||
interface SecretEnvelope {
|
||||
v: number;
|
||||
title?: string;
|
||||
cipher: string;
|
||||
kdf: string;
|
||||
iterations: number;
|
||||
salt: string;
|
||||
nonce: string;
|
||||
ct: string;
|
||||
}
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = '';
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function base64ToBytes(value: string, field: string): Uint8Array {
|
||||
const text = value.trim();
|
||||
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
|
||||
throw new Error(`Invalid secret ${field}`);
|
||||
}
|
||||
const binary = atob(text);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||
const copy = new Uint8Array(bytes.byteLength);
|
||||
copy.set(bytes);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function authenticatedMetadata(envelope: SecretEnvelope): string {
|
||||
return JSON.stringify({
|
||||
v: envelope.v,
|
||||
title: envelope.title ?? 'Encrypted secret',
|
||||
cipher: envelope.cipher,
|
||||
kdf: envelope.kdf,
|
||||
iterations: envelope.iterations,
|
||||
salt: envelope.salt,
|
||||
nonce: envelope.nonce,
|
||||
});
|
||||
}
|
||||
|
||||
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
|
||||
return encoder.encode(authenticatedMetadata(envelope));
|
||||
}
|
||||
|
||||
function parseEnvelope(payload: string): SecretEnvelope {
|
||||
let envelope: SecretEnvelope;
|
||||
try {
|
||||
envelope = JSON.parse(payload);
|
||||
} catch {
|
||||
throw new Error('Invalid secret payload');
|
||||
}
|
||||
if (
|
||||
envelope?.v !== SECRET_VERSION ||
|
||||
envelope.cipher !== SECRET_CIPHER ||
|
||||
envelope.kdf !== SECRET_KDF ||
|
||||
!Number.isInteger(envelope.iterations) ||
|
||||
envelope.iterations < 100_000 ||
|
||||
envelope.iterations > 5_000_000
|
||||
) {
|
||||
throw new Error('Unsupported secret payload');
|
||||
}
|
||||
return envelope;
|
||||
}
|
||||
|
||||
export function readSecretTitle(payload: string): string {
|
||||
try {
|
||||
const parsed = JSON.parse(payload);
|
||||
return typeof parsed?.title === 'string' && parsed.title.trim()
|
||||
? parsed.title.trim()
|
||||
: 'Encrypted secret';
|
||||
} catch {
|
||||
return 'Encrypted secret';
|
||||
}
|
||||
}
|
||||
|
||||
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
|
||||
if (!passphrase) throw new Error('Passphrase is required');
|
||||
const keyMaterial = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
encoder.encode(passphrase),
|
||||
'PBKDF2',
|
||||
false,
|
||||
['deriveKey'],
|
||||
);
|
||||
return crypto.subtle.deriveKey(
|
||||
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
|
||||
keyMaterial,
|
||||
{ name: 'AES-GCM', length: 256 },
|
||||
false,
|
||||
['encrypt', 'decrypt'],
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
|
||||
if (!plaintext) throw new Error('Secret text is required');
|
||||
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
|
||||
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
|
||||
const envelope: SecretEnvelope = {
|
||||
v: SECRET_VERSION,
|
||||
title: title.trim() || 'Encrypted secret',
|
||||
cipher: SECRET_CIPHER,
|
||||
kdf: SECRET_KDF,
|
||||
iterations: SECRET_ITERATIONS,
|
||||
salt: bytesToBase64(salt),
|
||||
nonce: bytesToBase64(nonce),
|
||||
ct: '',
|
||||
};
|
||||
const encrypted = await crypto.subtle.encrypt(
|
||||
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
|
||||
key,
|
||||
encoder.encode(plaintext),
|
||||
);
|
||||
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
|
||||
return JSON.stringify(envelope, null, 2);
|
||||
}
|
||||
|
||||
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
|
||||
try {
|
||||
const envelope = parseEnvelope(payload);
|
||||
const salt = base64ToBytes(envelope.salt, 'salt');
|
||||
const nonce = base64ToBytes(envelope.nonce, 'nonce');
|
||||
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
|
||||
const key = await deriveKey(passphrase, salt, envelope.iterations);
|
||||
const plaintext = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
|
||||
key,
|
||||
stableBytes(ciphertext),
|
||||
);
|
||||
return decoder.decode(plaintext);
|
||||
} catch {
|
||||
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
|
||||
}
|
||||
}
|
||||
@@ -24,3 +24,20 @@ export function formatDate(dateStr: string): string {
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
// Relative-date bucket label for grouping the notes list:
|
||||
// Today / Yesterday / Previous 7 Days / Previous 30 Days / "June" (this year) / "June 2025".
|
||||
// Buckets are contiguous, so a list already sorted by date groups by consecutive label.
|
||||
export function dateBucketLabel(dateStr: string, now: Date = new Date()): string {
|
||||
const d = new Date(dateStr);
|
||||
const dayMs = 86400000;
|
||||
const startOfDay = (x: Date) => new Date(x.getFullYear(), x.getMonth(), x.getDate()).getTime();
|
||||
const today = startOfDay(now);
|
||||
const day = startOfDay(d);
|
||||
if (day >= today) return 'Today';
|
||||
if (day >= today - dayMs) return 'Yesterday';
|
||||
if (day >= today - 7 * dayMs) return 'Previous 7 Days';
|
||||
if (day >= today - 30 * dayMs) return 'Previous 30 Days';
|
||||
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString(undefined, { month: 'long' });
|
||||
return d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||
}
|
||||
|
||||
@@ -1,27 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import '../app.css';
|
||||
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
|
||||
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
|
||||
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall, customThemes } from '$lib/stores/app';
|
||||
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
|
||||
import { get } from 'svelte/store';
|
||||
import { darkThemes, isMobile, isAndroid } from '$lib/platform';
|
||||
import type { CustomTheme } from '$lib/types';
|
||||
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const isMobile = /android|ios/i.test(navigator.userAgent);
|
||||
|
||||
// Reactively apply theme class to <html> whenever $theme changes
|
||||
// Reactively apply theme class to <html> whenever $theme or custom themes change
|
||||
$effect(() => {
|
||||
const t = $theme;
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
if (
|
||||
t === 'dark' ||
|
||||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
|
||||
) {
|
||||
root.classList.add('dark');
|
||||
applyTheme($theme, $customThemes);
|
||||
});
|
||||
|
||||
// Apply link arrow visibility from config
|
||||
$effect(() => {
|
||||
if ($appConfig) {
|
||||
document.documentElement.classList.toggle('no-link-arrows', !$appConfig.show_link_arrows);
|
||||
}
|
||||
});
|
||||
|
||||
const CUSTOM_THEME_VARS = [
|
||||
'--bg-primary', '--bg-secondary', '--bg-tertiary',
|
||||
'--bg-hover', '--bg-active', '--bg-editor',
|
||||
'--text-primary', '--text-secondary', '--border-color',
|
||||
];
|
||||
|
||||
function applyCustomThemeVars(root: HTMLElement, ct: CustomTheme) {
|
||||
root.style.setProperty('--bg-primary', ct.colors.bg_primary);
|
||||
root.style.setProperty('--bg-secondary', ct.colors.bg_secondary);
|
||||
root.style.setProperty('--bg-tertiary', ct.colors.bg_tertiary);
|
||||
root.style.setProperty('--bg-hover', ct.colors.bg_hover);
|
||||
root.style.setProperty('--bg-active', ct.colors.bg_active);
|
||||
root.style.setProperty('--bg-editor', ct.colors.bg_editor);
|
||||
root.style.setProperty('--text-primary', ct.colors.text_primary);
|
||||
root.style.setProperty('--text-secondary', ct.colors.text_secondary);
|
||||
root.style.setProperty('--border-color', ct.colors.border_color);
|
||||
}
|
||||
|
||||
function clearCustomThemeVars(root: HTMLElement) {
|
||||
for (const v of CUSTOM_THEME_VARS) root.style.removeProperty(v);
|
||||
}
|
||||
|
||||
function applyTheme(t: string, themes: CustomTheme[] = []) {
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
const root = document.documentElement;
|
||||
root.classList.remove('dark');
|
||||
root.removeAttribute('data-theme');
|
||||
clearCustomThemeVars(root);
|
||||
if (t.startsWith('custom-')) {
|
||||
const ct = themes.find(c => c.id === t);
|
||||
if (ct) {
|
||||
applyCustomThemeVars(root, ct);
|
||||
if (ct.is_dark) root.classList.add('dark');
|
||||
}
|
||||
} else if (namedThemes.includes(t)) {
|
||||
root.setAttribute('data-theme', t);
|
||||
if (darkThemes.includes(t)) root.classList.add('dark');
|
||||
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
const parts = p.split('/');
|
||||
const resolved: string[] = [];
|
||||
@@ -32,8 +74,6 @@
|
||||
return resolved.join('/');
|
||||
}
|
||||
|
||||
const isAndroid = /android/i.test(navigator.userAgent);
|
||||
|
||||
function openLocalFile(path: string) {
|
||||
if (isAndroid) {
|
||||
const bridge = (window as any).Android;
|
||||
@@ -46,7 +86,7 @@
|
||||
}
|
||||
|
||||
function resolveAndHandleLink(href: string) {
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
|
||||
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
|
||||
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
|
||||
} else if (!href.startsWith('#')) {
|
||||
const decoded = decodeURIComponent(href);
|
||||
@@ -81,12 +121,16 @@
|
||||
|
||||
// Detect install type and check for updates on startup
|
||||
onMount(() => {
|
||||
// Authoritative platform from the backend (compile-time), overriding the UA guess. (#63)
|
||||
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
|
||||
if (isMobile) {
|
||||
installType.set('android');
|
||||
checkForUpdateMobile();
|
||||
// Android updates come from the F-droid repo / Obtainium, so no in-app update check.
|
||||
} else {
|
||||
getInstallType().then(t => installType.set(t)).catch(() => {});
|
||||
checkForUpdate();
|
||||
getInstallType().then(t => {
|
||||
installType.set(t);
|
||||
if (!isManagedInstall(t)) checkForUpdate();
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -160,6 +204,25 @@
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// On Windows the native OS drag-drop handler is disabled (dragDropEnabled:false
|
||||
// in tauri.windows.conf.json) so HTML5 drag-and-drop works for reordering. With it
|
||||
// off, a file dropped outside a drop zone would make the webview navigate to / open
|
||||
// that file, replacing the app. Swallow any drag that bubbles up unhandled. Real drop
|
||||
// zones (editor, sidebar reordering) call preventDefault in their own handlers first;
|
||||
// these bubble-phase listeners only act as a fallback. On macOS/Linux OS file drops are
|
||||
// intercepted natively and never surface as HTML5 events, so this is a harmless no-op there.
|
||||
onMount(() => {
|
||||
function preventNavigate(e: DragEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
window.addEventListener('dragover', preventNavigate);
|
||||
window.addEventListener('drop', preventNavigate);
|
||||
return () => {
|
||||
window.removeEventListener('dragover', preventNavigate);
|
||||
window.removeEventListener('drop', preventNavigate);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
|
||||
@@ -169,3 +232,5 @@
|
||||
</svelte:head>
|
||||
|
||||
{@render children()}
|
||||
|
||||
<ResizeHandles />
|
||||
|
||||
@@ -1,13 +1,109 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { appConfig, vaultReady, theme } from '$lib/stores/app';
|
||||
import { getAppConfig, openVault } from '$lib/api';
|
||||
import { getAppConfig, openVault, setFontSize } from '$lib/api';
|
||||
import { darkThemes } from '$lib/platform';
|
||||
import { getCurrentWebview } from '@tauri-apps/api/webview';
|
||||
import VaultPicker from '$lib/components/VaultPicker.svelte';
|
||||
import AppLayout from '$lib/components/AppLayout.svelte';
|
||||
import NoteWindow from '$lib/components/NoteWindow.svelte';
|
||||
|
||||
let loading = $state(true);
|
||||
let noteWindowPath = $state<string | null>(null);
|
||||
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let removeEditorZoomShortcuts: (() => void) | null = null;
|
||||
|
||||
const defaultEditorFontSize = 14;
|
||||
const minEditorFontSize = 10;
|
||||
const maxEditorFontSize = 32;
|
||||
|
||||
function getEditorScrollSurface(): HTMLElement | HTMLTextAreaElement | null {
|
||||
const sourceEditor = document.querySelector<HTMLTextAreaElement>('.source-editor');
|
||||
if (sourceEditor && getComputedStyle(sourceEditor).display !== 'none') return sourceEditor;
|
||||
return document.querySelector<HTMLElement>('.editor-body');
|
||||
}
|
||||
|
||||
function preserveEditorViewportAfterLayout(scrollSurface: HTMLElement | HTMLTextAreaElement | null) {
|
||||
if (!scrollSurface) return () => {};
|
||||
|
||||
const isSourceEditor = scrollSurface instanceof HTMLTextAreaElement;
|
||||
const maxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
|
||||
const scrollRatio = maxScroll > 0 ? scrollSurface.scrollTop / maxScroll : 0;
|
||||
const rect = scrollSurface.getBoundingClientRect();
|
||||
const anchorY = Math.min(rect.bottom - 1, rect.top + Math.max(16, rect.height * 0.25));
|
||||
let anchorElement: HTMLElement | null = null;
|
||||
let anchorOffset = 0;
|
||||
|
||||
if (!isSourceEditor) {
|
||||
const candidate = document.elementFromPoint(rect.left + Math.min(80, rect.width / 2), anchorY);
|
||||
anchorElement = candidate?.closest('.tiptap *') as HTMLElement | null;
|
||||
if (anchorElement) anchorOffset = anchorElement.getBoundingClientRect().top - rect.top;
|
||||
}
|
||||
|
||||
return () => {
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
if (anchorElement && scrollSurface.contains(anchorElement)) {
|
||||
const nextRect = scrollSurface.getBoundingClientRect();
|
||||
scrollSurface.scrollTop += anchorElement.getBoundingClientRect().top - nextRect.top - anchorOffset;
|
||||
} else {
|
||||
const nextMaxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
|
||||
scrollSurface.scrollTop = Math.max(0, nextMaxScroll * scrollRatio);
|
||||
}
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function setEditorFontSize(value: number) {
|
||||
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
|
||||
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
|
||||
if ($appConfig) $appConfig.font_size = nextSize;
|
||||
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
|
||||
restoreEditorViewport();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
fontSizeSaveTimer = setTimeout(() => {
|
||||
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
|
||||
fontSizeSaveTimer = null;
|
||||
}, 150);
|
||||
}
|
||||
|
||||
function installEditorZoomShortcuts() {
|
||||
const zoomEditor = (delta: number) => setEditorFontSize(($appConfig?.font_size ?? defaultEditorFontSize) + delta);
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
if (event.altKey || event.shiftKey) return;
|
||||
|
||||
if (event.key === '+' || event.key === '=') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomEditor(1);
|
||||
} else if (event.key === '-' || event.key === '_') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomEditor(-1);
|
||||
} else if (event.key === '0') {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setEditorFontSize(defaultEditorFontSize);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (event: WheelEvent) => {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
zoomEditor(event.deltaY < 0 ? 1 : -1);
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
window.removeEventListener('wheel', handleWheel, { capture: true });
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
// Check for note window mode
|
||||
@@ -25,8 +121,13 @@
|
||||
// Apply theme immediately to prevent flash
|
||||
const themeValue = config.theme || 'system';
|
||||
const root = document.documentElement;
|
||||
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach', 'material-dark', 'material-light', 'monokai', 'rose-pine', 'everforest', 'horizon', 'cyberpunk', 'black', 'one-dark'];
|
||||
root.classList.remove('dark');
|
||||
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.removeAttribute('data-theme');
|
||||
if (namedThemes.includes(themeValue)) {
|
||||
root.setAttribute('data-theme', themeValue);
|
||||
if (darkThemes.includes(themeValue)) root.classList.add('dark');
|
||||
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
|
||||
root.classList.add('dark');
|
||||
}
|
||||
|
||||
@@ -37,6 +138,9 @@
|
||||
if (config.line_height) {
|
||||
document.documentElement.style.setProperty('--editor-line-height', String(config.line_height));
|
||||
}
|
||||
if (config.content_width) {
|
||||
document.documentElement.style.setProperty('--editor-content-width', `${config.content_width}px`);
|
||||
}
|
||||
if (config.font_family) {
|
||||
const fontStacks: Record<string, string> = {
|
||||
system: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif',
|
||||
@@ -53,21 +157,31 @@
|
||||
}
|
||||
// Apply saved accent
|
||||
if (config.accent_color) {
|
||||
const accentPresets: Record<string, { light: string; dark: string }> = {
|
||||
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
|
||||
Rose: { light: '#e11d48', dark: '#d4768a' },
|
||||
Emerald: { light: '#059669', dark: '#5bab8a' },
|
||||
Amber: { light: '#d97706', dark: '#c9a04e' },
|
||||
Purple: { light: '#7c3aed', dark: '#9a82c4' },
|
||||
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
|
||||
Orange: { light: '#ea580c', dark: '#c98560' },
|
||||
Teal: { light: '#0d9488', dark: '#5fada5' },
|
||||
};
|
||||
const preset = accentPresets[config.accent_color];
|
||||
if (preset) {
|
||||
const themeVal = config.theme || 'light';
|
||||
const isDark = themeVal === 'dark' || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
const color = isDark ? preset.dark : preset.light;
|
||||
const themeVal = config.theme || 'light';
|
||||
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
let color: string | null = null;
|
||||
if (config.accent_color.startsWith('#')) {
|
||||
color = config.accent_color;
|
||||
} else {
|
||||
const accentPresets: Record<string, { light: string; dark: string }> = {
|
||||
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
|
||||
Rose: { light: '#e11d48', dark: '#d4768a' },
|
||||
Emerald: { light: '#059669', dark: '#5bab8a' },
|
||||
Amber: { light: '#d97706', dark: '#c9a04e' },
|
||||
Purple: { light: '#7c3aed', dark: '#9a82c4' },
|
||||
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
|
||||
Orange: { light: '#ea580c', dark: '#c98560' },
|
||||
Teal: { light: '#0d9488', dark: '#5fada5' },
|
||||
Sakura: { light: '#d4547a', dark: '#f4a0b5' },
|
||||
Gold: { light: '#b8860b', dark: '#fabd2f' },
|
||||
Lavender: { light: '#9d5cdb', dark: '#c084fc' },
|
||||
Sky: { light: '#0284c7', dark: '#38bdf8' },
|
||||
Coral: { light: '#e85d4a', dark: '#ff8a7a' },
|
||||
};
|
||||
const preset = accentPresets[config.accent_color];
|
||||
if (preset) color = isDark ? preset.dark : preset.light;
|
||||
}
|
||||
if (color) {
|
||||
const root = document.documentElement;
|
||||
root.style.setProperty('--accent', color);
|
||||
root.style.setProperty('--text-accent', color);
|
||||
@@ -78,6 +192,16 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Apply saved interface scale (desktop main window only; no-ops gracefully elsewhere)
|
||||
if (!noteWindowPath && config.ui_scale && config.ui_scale !== 1) {
|
||||
try {
|
||||
await getCurrentWebview().setZoom(config.ui_scale);
|
||||
} catch (e) {
|
||||
console.error('Failed to apply interface scale:', e);
|
||||
}
|
||||
}
|
||||
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
|
||||
|
||||
// Auto-open last vault if available
|
||||
if (config.active_vault) {
|
||||
try {
|
||||
@@ -95,6 +219,11 @@
|
||||
}
|
||||
loading = false;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
removeEditorZoomShortcuts?.();
|
||||
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if loading}
|
||||
|
||||