Compare commits

...
41 Commits
Author SHA1 Message Date
Yuri Karamian 000d68a0e7 Fix drag & drop on macOS 2026-06-10 10:22:31 +02:00
Yuri Karamian d8c391a480 v1.3.0 2026-06-10 09:25:18 +02:00
Yuri Karamian 459af56564 Edit note tags from within the editor: tag icon by pin/star opens an add/remove popover with autocomplete, and the metadata tag line is clickable (#60) 2026-06-08 22:07:23 +02:00
Yuri Karamian 9d82f2aed7 tidy AppConfig types, md-it shims, dead CSS 2026-06-08 21:21:54 +02:00
Yuri Karamian 4fb99c7e39 Fix top-of-note collapsible section re-opening on note switch (persist its folded state) issue #86 2026-06-08 21:11:35 +02:00
Yuri Karamian 92e8422cb0 Add 'Start week on' setting (Monday default / Sunday) for daily and tasks calendars 2026-06-08 20:56:28 +02:00
Yuri Karamian 5defddb35c Add tag autocomplete: suggest existing tags while typing to avoid near-duplicates 2026-06-08 12:51:03 +02:00
David Hain dc19976568 Update the Nix Flake cargoHash (#85)
To make the Nix build work again.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/85
2026-06-08 10:50:39 +02:00
Yuri Karamian bd59f6dbee Enhance calendars: unify daily and Tasks calendars, remember List/Calendar choice per vault 2026-06-08 02:53:34 +02:00
Yuri Karamian 26fa22afaa Add task management: aggregated Tasks view (list + calendar) with inline due/priority, check off, set from row, and drag-to-reschedule (#62) 2026-06-08 02:34:19 +02:00
Yuri Karamian e2702bdd51 Create new notes in the open note's notebook when not in a notebook view 2026-06-08 01:20:21 +02:00
Yuri Karamian 0d0ecee06e Add 'Restore last session on launch' setting (reopen last note + view) 2026-06-08 01:12:32 +02:00
Yuri Karamian 6115ad4ab6 Add 'Show dates' setting to toggle the note-list date 2026-06-08 01:00:25 +02:00
Yuri Karamian a400eeefed Store search index in local app-data, not the synced vault (fixes OneDrive churn) 2026-06-08 00:52:45 +02:00
Yuri Karamian f19ddee17d Persist note-list sort mode across restarts; date follows sort + tooltip 2026-06-08 00:47:05 +02:00
Yuri Karamian 393505e1ac Note list date follows sort mode + tooltip 2026-06-08 00:42:36 +02:00
Yuri Karamian b87e8b8a38 Treat Unfiled Notes as root when creating a new notebook 2026-06-08 00:32:43 +02:00
Yuri Karamian 5b62302b3a Refresh notebook note counts immediately after creating a note 2026-06-08 00:31:55 +02:00
Yuri Karamian ee0ffdf121 Allow creating a top-level notebook while a notebook is selected (clearable parent chip) 2026-06-08 00:26:39 +02:00
Yuri Karamian 89eea8e71b Add color swatch previews and /color insert 2026-06-08 00:04:38 +02:00
Yuri Karamian f27512c7ee Enhance Sync settings section 2026-06-07 23:41:20 +02:00
Yuri Karamian eafd2b0a24 Add WebDAV sync provider (manual + auto-sync, title-bar button) 2026-06-07 23:25:47 +02:00
Yuri Karamian aeafd55491 Add WebDAV sync provider (manual + auto-sync, top-bar button) 2026-06-07 23:14:04 +02:00
Yuri Karamian ccc5e3b7c6 Fix doubled safe-area inset causing top/bottom gap on Android 2026-06-07 19:11:59 +02:00
Yuri Karamian a28e514ed4 Fix Windows wiki-link/graph showing duplicate and old-version notes (path separators) 2026-06-07 18:55:12 +02:00
Yuri Karamian 7c94370e48 Add CJK (Chinese/Japanese/Korean) tokenizer for full-text search 2026-06-07 17:44:05 +02:00
Yuri Karamian ab39fc2ed6 Update Anthropic/OpenAI model lists (Opus 4.8, GPT-5.4/5.5) 2026-06-07 17:23:11 +02:00
Yuri Karamian 621e2b31e0 Add date/time insertion (slash, toolbar, context menu; ISO format) 2026-06-07 16:18:43 +02:00
Yuri Karamian ca3900815b Allow toggling task-list checkboxes in View Mode 2026-06-07 12:54:13 +02:00
Yuri Karamian b36ce9ed5b Fix code auto-linkify (#80) and currency text parsed as math (#81) 2026-06-06 14:36:52 +02:00
Yuri Karamian 59677577ec Fix unreliable window resize on Linux with custom resize handles (#77) 2026-06-05 18:27:08 +02:00
Yuri Karamian 71a0b9c0a0 Show mobile AI button for all configured providers 2026-06-05 11:21:11 +02:00
qyrhalandmidhun kumar 3876b7db96 Fix issue of ai features not appearing when configured (#78) (#79)
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/79
2026-06-05 11:17:56 +02:00
blake 0436136533 Added Solarized, Github, Nord, Tokyo Night, Dracula, and Catppuccin themes. (#76)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/76
2026-06-04 21:49:49 +02:00
Yuri Karamian 139ecc8176 Add Interface Scale setting to zoom the whole app (80-200%) 2026-06-04 19:27:00 +02:00
qyrhalandmidhun kumar 4b82d51f6c Added openai compatible v1 completions and ollama api key compatibility (#73) (#74)
Added:
    - v1 completions (OpenAI compatible) and testing connection
    - Add API key handling for Ollama and v1 completions if required
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/74
2026-06-03 18:49:11 +02:00
Yuri Karamian 8ceb44df78 Remove unused fastlane metadata 2026-06-03 14:43:46 +02:00
Yuri Karamian 3b3e7fbbe6 Merge branch 'main' of ssh://codeberg.org/ArkHost/HelixNotes 2026-06-03 14:23:29 +02:00
Yuri Karamian 7cbd112585 Cross-platform groundwork: platform detection, iOS config, Windows drag-drop fix, Android 10 storage fix (#72), bundled fonts 2026-06-03 14:11:10 +02:00
doceys d275b0e783 Build on NixOS (#49)
feat: add Nix flake for NixOS/darwin builds (#49)
2026-04-30 11:31:47 +02:00
Yuri Karamian fedbdfd2a1 update README.md 2026-04-27 23:23:40 +02:00
95 changed files with 5754 additions and 369 deletions
+17 -1
View File
@@ -1,5 +1,10 @@
node_modules node_modules
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
package-lock.json
yarn.lock
bun.lockb
# Output # Output
.output .output
.vercel .vercel
@@ -8,9 +13,20 @@ node_modules
/.svelte-kit /.svelte-kit
/build /build
# Nix
/result
# Tauri # Tauri
/src-tauri/target /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 # OS
.DS_Store .DS_Store
+70 -8
View File
@@ -1,7 +1,7 @@
# HelixNotes # HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.8-green)](https://codeberg.org/ArkHost/HelixNotes/releases) [![Latest Release](https://img.shields.io/badge/release-v1.2.9-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]() [![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -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. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.2.8) ## Download (v1.2.9)
### Linux ### Linux
@@ -26,25 +26,83 @@ 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 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
``` ```
#### 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) #### 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.2.9/HelixNotes_1.2.9_amd64.AppImage)
#### .deb (manual) #### .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.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
### Windows ### 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.2.9/HelixNotes_1.2.9_x64-setup.exe) (Windows 10/11)
### macOS ### 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](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android ### 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.2.9/HelixNotes_1.2.9_android.apk)
--- ---
@@ -56,7 +114,11 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
- `[[Wiki-links]]` and graph view - `[[Wiki-links]]` and graph view
- Full-text search (Tantivy) - Full-text search (Tantivy)
- Outline panel, daily notes with calendar view, tags, drag-and-drop - Outline panel, daily notes with calendar view, tags, drag-and-drop
- Math (KaTeX), PDF preview, Obsidian import - 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)
- 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 / Anthropic / OpenAI) - AI writing tools (Ollama / Anthropic / OpenAI)
- Version history with diffs, automatic backups - Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode - Multi-window, file associations, focus mode, view mode
@@ -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
Generated
+61
View File
@@ -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
}
+76
View File
@@ -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-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
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;
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes", "name": "helixnotes",
"private": true, "private": true,
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"version": "1.2.9", "version": "1.3.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite dev", "dev": "vite dev",
+21 -3
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]] [[package]]
name = "helixnotes" name = "helixnotes"
version = "1.2.9" version = "1.3.0"
dependencies = [ dependencies = [
"arboard", "arboard",
"chrono", "chrono",
@@ -1861,6 +1861,7 @@ dependencies = [
"log", "log",
"notify", "notify",
"png 0.17.16", "png 0.17.16",
"quick-xml 0.36.2",
"rayon", "rayon",
"regex", "regex",
"reqwest 0.12.28", "reqwest 0.12.28",
@@ -1868,6 +1869,7 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"serde_yaml", "serde_yaml",
"sha2",
"sys-locale", "sys-locale",
"tantivy", "tantivy",
"tauri", "tauri",
@@ -1880,6 +1882,7 @@ dependencies = [
"tauri-plugin-updater", "tauri-plugin-updater",
"tauri-plugin-window-state", "tauri-plugin-window-state",
"tokio", "tokio",
"urlencoding",
"uuid", "uuid",
"walkdir", "walkdir",
"zip 2.4.2", "zip 2.4.2",
@@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.13.0", "indexmap 2.13.0",
"quick-xml", "quick-xml 0.38.4",
"serde", "serde",
"time", "time",
] ]
@@ -3612,6 +3615,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" 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]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.38.4" version = "0.38.4"
@@ -5911,6 +5923,12 @@ dependencies = [
"serde_derive", "serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "urlpattern" name = "urlpattern"
version = "0.3.0" version = "0.3.0"
@@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3" checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml", "quick-xml 0.38.4",
"quote", "quote",
] ]
+5 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "helixnotes" name = "helixnotes"
version = "1.2.9" version = "1.3.0"
description = "Local markdown note-taking app" description = "Local markdown note-taking app"
authors = ["HelixNotes"] authors = ["HelixNotes"]
license = "AGPL-3.0-or-later" 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"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3" futures = "0.3"
rayon = "1" rayon = "1"
quick-xml = "0.36"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } 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-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2" tauri-plugin-window-state = "2"
+1
View File
@@ -7,6 +7,7 @@
"core:default", "core:default",
"core:window:default", "core:window:default",
"core:window:allow-start-dragging", "core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"core:window:allow-close", "core:window:allow-close",
+2 -1
View File
@@ -6,6 +6,7 @@
"windows": ["main"], "windows": ["main"],
"permissions": [ "permissions": [
"updater:default", "updater:default",
"window-state:default" "window-state:default",
"core:webview:allow-set-webview-zoom"
] ]
} }
+12
View File
@@ -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
+19
View File
@@ -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
+6
View File
@@ -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")
+22
View File
@@ -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,150 @@
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")
}
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>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

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>
+22
View File
@@ -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
)
}
}
}
}
}
+24
View File
@@ -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
Binary file not shown.
@@ -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
+185
View File
@@ -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" "$@"
+89
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+192 -2
View File
@@ -21,6 +21,8 @@ pub fn ai_request(
std::thread::spawn(move || { std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap(); let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async { 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() { let result = match provider.as_str() {
"openai" => { "openai" => {
stream_openai( stream_openai(
@@ -40,7 +42,7 @@ pub fn ai_request(
stream_openai( stream_openai(
&app, &app,
&url, &url,
None, key_opt,
&model, &model,
&system_prompt, &system_prompt,
&user_message, &user_message,
@@ -48,6 +50,24 @@ pub fn ai_request(
) )
.await .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/completions", url.trim_end_matches('/'));
stream_completions(
&app,
&url,
key_opt,
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
}
_ => { _ => {
stream_anthropic( stream_anthropic(
&app, &app,
@@ -353,12 +373,21 @@ pub async fn test_connection(
model: &str, model: &str,
base_url: Option<&str>, base_url: Option<&str>,
) -> Result<String, String> { ) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
match provider { match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await, "openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => { "ollama" => {
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL); let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/')); 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/completions", url.trim_end_matches('/'));
test_completions(&url, key_opt, model).await
} }
_ => test_anthropic(api_key, model).await, _ => test_anthropic(api_key, model).await,
} }
@@ -397,6 +426,167 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
} }
} }
async fn stream_completions(
app: &AppHandle,
url: &str,
api_key: Option<&str>,
model: &str,
system_prompt: &str,
user_message: &str,
_request_id: &str,
) -> Result<(), String> {
let client = Client::new();
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
let body = json!({
"model": model,
"prompt": prompt,
"max_tokens": 4096,
"stream": true,
"temperature": 0.7
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
return Err(format!("API error {}: {}", status, body_text));
}
// Parse SSE stream
use futures::StreamExt;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete SSE events from buffer
while let Some(event_end) = buffer.find("\n\n") {
let event_str = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
for line in event_str.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
// Completions streaming: choices[0].text
if let Some(text) = parsed["choices"][0]["text"].as_str() {
if !text.is_empty() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "text".to_string(),
text: Some(text.to_string()),
error: None,
},
);
}
}
// Check finish_reason
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
if reason == "stop" || reason == "length" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
}
// Check for error in stream
if let Some(err) = parsed["error"]["message"].as_str() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "error".to_string(),
text: None,
error: Some(err.to_string()),
},
);
return Err(err.to_string());
}
}
}
}
}
}
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
Ok(())
}
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let body = json!({
"model": model,
"prompt": "Hi",
"max_tokens": 10,
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
if response.status().is_success() {
Ok("Connection successful".to_string())
} else {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
Err(format!("API error {}: {}", status, body_text))
}
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> { async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
+443 -38
View File
@@ -11,18 +11,18 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?; operations::ensure_vault_structure(&path)?;
// Initialize search index - rebuild in background on Android (FUSE is slow) // Initialize search index - rebuild in background on mobile (sandboxed FS is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?); let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")] #[cfg(mobile)]
{ {
let search_bg = search.clone(); let search_bg = search.clone();
let vault = path.clone(); let vault = path.clone();
std::thread::spawn(move || { std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault); let _ = search_bg.rebuild(&vault);
log::info!("Android: search index rebuild complete"); log::info!("mobile: search index rebuild complete");
}); });
} }
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
{ {
search.rebuild(&path)?; search.rebuild(&path)?;
} }
@@ -103,6 +103,14 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
Ok(()) Ok(())
} }
#[tauri::command]
pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.ui_scale = Some(scale);
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ── // ── Notebooks ──
#[tauri::command] #[tauri::command]
@@ -379,10 +387,14 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
let config = state.config.lock().map_err(|e| e.to_string())?; let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path); let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let mut entries = Vec::new(); let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(vault) for entry in walkdir::WalkDir::new(vault)
.into_iter() .into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths, which leaked .helixnotes/history snapshots in as dupes).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
@@ -390,9 +402,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
continue; continue;
} }
let path_str = path.to_string_lossy(); 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") { if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue; continue;
} }
@@ -441,15 +450,17 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let mut seen_paths: HashSet<String> = HashSet::new(); let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new(); let mut contents: Vec<String> = Vec::new();
let hn_dir = operations::helixnotes_dir(vault_path);
for entry in walkdir::WalkDir::new(vault) for entry in walkdir::WalkDir::new(vault)
.into_iter() .into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths; is_hidden also covers .stversions/.stfolder/.trash/.git).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.is_file() { continue; } if !path.is_file() { continue; }
let path_str = path.to_string_lossy().to_string(); let path_str = path.to_string_lossy().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; } if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files // Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) { if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
@@ -475,7 +486,8 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
// Also index by vault-relative path without extension (e.g. "subfolder/note name") // Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) { if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension(""); let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase(); // Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
let rel_lower = rel_no_ext.to_string_lossy().replace('\\', "/").to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx); relpath_to_idx.entry(rel_lower).or_insert(idx);
} }
@@ -577,6 +589,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
None None
} }
// ── Tasks ──
#[tauri::command]
pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let task_re = regex::Regex::new(r"^\s*[-*]\s\[([ xX])\]\s+(.+?)\s*$").unwrap();
let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap();
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap();
let mut tasks = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => continue,
};
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let note_path = path.to_string_lossy().to_string();
for (i, line) in body.lines().enumerate() {
let caps = match task_re.captures(line) {
Some(c) => c,
None => continue,
};
let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open
let content = caps[2].to_string();
let due = due_re.captures(&content).map(|c| c[1].to_string());
let priority = prio_re.captures(&content).map(|c| {
let p = c[1].to_lowercase();
if p == "medium" { "med".to_string() } else { p }
});
// Strip metadata tokens for the display text, then collapse whitespace.
let mut text = due_re.replace_all(&content, "").to_string();
text = prio_re.replace_all(&text, " ").to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
tasks.push(crate::types::TaskItem {
note_path: note_path.clone(),
note_title: meta.title.clone(),
line: i,
raw_line: line.to_string(),
text,
completed,
due,
priority,
});
}
}
Ok(tasks)
}
fn toggle_checkbox_line(line: &str, done: bool) -> String {
let mut s = line.to_string();
if done {
if let Some(pos) = s.find("[ ]") {
s.replace_range(pos..pos + 3, "[x]");
}
} else {
for marker in ["[x]", "[X]"] {
if let Some(pos) = s.find(marker) {
s.replace_range(pos..pos + 3, "[ ]");
break;
}
}
}
s
}
#[tauri::command]
pub fn set_task_done(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
done: bool,
) -> Result<(), String> {
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
// Verify the expected line; if the note drifted, fall back to the first exact match.
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let toggled = toggle_checkbox_line(&lines[idx], done);
if toggled == lines[idx] {
return Ok(()); // already in the desired state
}
lines[idx] = toggled;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_priority_on_line(line: &str, priority: Option<&str>) -> String {
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(?:high|medium|med|low)\b").unwrap();
let stripped = prio_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match priority {
Some(p) => format!("{} !{}", stripped, p),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_priority(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
priority: Option<String>,
) -> Result<(), String> {
// Normalize/validate priority ("medium" -> "med"); None clears it.
let prio = match priority.as_deref() {
None | Some("") | Some("none") => None,
Some("high") => Some("high"),
Some("med") | Some("medium") => Some("med"),
Some("low") => Some("low"),
Some(_) => return Err("Invalid priority".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_priority_on_line(&lines[idx], prio);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_due_on_line(line: &str, due: Option<&str>) -> String {
let due_re = regex::Regex::new(r"(?:^|\s)due:\d{4}-\d{2}-\d{2}\b").unwrap();
let stripped = due_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match due {
Some(d) => format!("{} due:{}", stripped, d),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_due(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
due: Option<String>,
) -> Result<(), String> {
// Validate format (YYYY-MM-DD); None/empty clears the due date.
let date_re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
let due_val: Option<&str> = match due.as_deref() {
None | Some("") => None,
Some(d) if date_re.is_match(d) => Some(d),
Some(_) => return Err("Invalid due date".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_due_on_line(&lines[idx], due_val);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
// ── Search ── // ── Search ──
#[tauri::command] #[tauri::command]
@@ -670,7 +930,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug). /// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard. /// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
#[tauri::command] #[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> { pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard = let mut clipboard =
@@ -695,14 +955,14 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Ok(buf) Ok(buf)
} }
#[cfg(target_os = "android")] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> { pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string()) Err("Clipboard image reading not supported on Android".to_string())
} }
/// Copy an image file to the system clipboard. /// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
#[tauri::command] #[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> { pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?; let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
@@ -722,14 +982,14 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(target_os = "android")] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> { pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string()) Err("Clipboard image copy not supported on Android".to_string())
} }
/// Copy PNG bytes directly to the system clipboard. /// Copy PNG bytes directly to the system clipboard.
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
#[tauri::command] #[tauri::command]
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> { pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data) let img = image::load_from_memory(&data)
@@ -748,7 +1008,7 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(target_os = "android")] #[cfg(mobile)]
#[tauri::command] #[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> { pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string()) Err("Clipboard image copy not supported on Android".to_string())
@@ -807,6 +1067,7 @@ pub fn set_general_settings(
state: State<'_, AppState>, state: State<'_, AppState>,
compact_notes: bool, compact_notes: bool,
time_format: String, time_format: String,
week_start: String,
gpu_acceleration: bool, gpu_acceleration: bool,
autostart: bool, autostart: bool,
pdf_preview: bool, pdf_preview: bool,
@@ -818,10 +1079,15 @@ pub fn set_general_settings(
show_tray_icon: bool, show_tray_icon: bool,
close_to_tray: bool, close_to_tray: bool,
enable_wiki_links: bool, enable_wiki_links: bool,
show_note_dates: bool,
restore_last_session: bool,
) -> Result<(), String> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes; config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session;
config.time_format = time_format; config.time_format = time_format;
config.week_start = week_start;
config.gpu_acceleration = gpu_acceleration; config.gpu_acceleration = gpu_acceleration;
config.autostart = autostart; config.autostart = autostart;
config.pdf_preview = pdf_preview; config.pdf_preview = pdf_preview;
@@ -1654,12 +1920,22 @@ pub fn set_ai_settings(
model: String, model: String,
writing_style: Option<String>, writing_style: Option<String>,
base_url: Option<String>, base_url: Option<String>,
ollama_api_key: Option<String>,
openai_compatible_base_url: Option<String>,
openai_compatible_api_key: Option<String>,
) -> Result<(), String> { ) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
let key = api_key.filter(|k| !k.is_empty()); let key = api_key.filter(|k| !k.is_empty());
match provider.as_deref() { match provider.as_deref() {
Some("openai") => config.openai_api_key = key, Some("openai") => config.openai_api_key = key,
Some("ollama") => config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()), Some("ollama") => {
config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty());
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
}
Some("openai_compatible") => {
config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty());
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
}
_ => config.ai_api_key = key, _ => config.ai_api_key = key,
} }
config.ai_provider = provider; config.ai_provider = provider;
@@ -1678,17 +1954,18 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
.ai_provider .ai_provider
.clone() .clone()
.unwrap_or_else(|| "anthropic".to_string()); .unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" { let key = match provider.as_str() {
// Ollama doesn't require an API key "ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
Some(String::new()) "openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
} else if provider == "openai" { "openai" => config.openai_api_key.clone(),
config.openai_api_key.clone() _ => config.ai_api_key.clone(),
} else {
config.ai_api_key.clone()
} }
.ok_or("No API key configured")?; .ok_or("No API key configured")?;
let model = config.ai_model.clone(); let model = config.ai_model.clone();
let base_url = config.ollama_base_url.clone(); let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, base_url) (provider, key, model, base_url)
}; };
@@ -1719,6 +1996,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
// ── Sync (WebDAV) ──
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
if config.sync_provider.as_deref() != Some("webdav") {
return Err("Sync is not configured".to_string());
}
let url = config
.webdav_url
.clone()
.filter(|u| !u.trim().is_empty())
.ok_or("WebDAV URL is not set")?;
Ok(crate::sync::WebdavConfig {
url,
username: config.webdav_username.clone().unwrap_or_default(),
password: config.webdav_password.clone().unwrap_or_default(),
})
}
#[tauri::command]
pub fn set_sync_settings(
state: State<'_, AppState>,
provider: Option<String>,
url: Option<String>,
username: Option<String>,
password: Option<String>,
sync_on_open: bool,
sync_on_change: bool,
sync_interval_minutes: u32,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.sync_provider = provider.filter(|p| !p.is_empty());
config.webdav_url = url.filter(|u| !u.trim().is_empty());
config.webdav_username = username.filter(|u| !u.is_empty());
config.webdav_password = password.filter(|p| !p.is_empty());
config.sync_on_open = sync_on_open;
config.sync_on_change = sync_on_change;
config.sync_interval_minutes = sync_interval_minutes;
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn test_sync_connection(app: AppHandle) -> Result<(), String> {
let cfg = {
let state = app.state::<AppState>();
let config = state.config.lock().map_err(|e| e.to_string())?;
sync_config_from(&config)?
};
std::thread::spawn(move || {
use tauri::Emitter;
match crate::sync::test_connection(cfg) {
Ok(msg) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": true, "message": msg }),
);
}
Err(e) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": false, "error": e }),
);
}
}
});
Ok(())
}
#[tauri::command]
pub fn sync_now(app: AppHandle) -> Result<(), String> {
use std::sync::atomic::Ordering;
// Guard against overlapping syncs (manual button + interval + on-change can collide).
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
return Ok(()); // a sync is already running
}
let (vault, cfg) = {
let state = app.state::<AppState>();
let config = match state.config.lock() {
Ok(c) => c,
Err(e) => {
state.syncing.store(false, Ordering::SeqCst);
return Err(e.to_string());
}
};
let gathered = config
.active_vault
.clone()
.ok_or_else(|| "No active vault".to_string())
.and_then(|v| sync_config_from(&config).map(|c| (v, c)));
match gathered {
Ok(vc) => vc,
Err(e) => {
drop(config);
state.syncing.store(false, Ordering::SeqCst);
return Err(e);
}
}
};
std::thread::spawn(move || {
use tauri::Emitter;
let result = crate::sync::run_sync(app.clone(), vault, cfg);
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
match result {
Ok(summary) => {
let ts = chrono::Utc::now().to_rfc3339();
if let Ok(mut config) = app.state::<AppState>().config.lock() {
config.last_sync_time = Some(ts.clone());
let _ = save_app_config(&config);
}
let _ = app.emit(
"sync-done",
serde_json::json!({ "success": true, "summary": summary, "last_sync_time": ts }),
);
}
Err(e) => {
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
}
}
});
Ok(())
}
#[tauri::command] #[tauri::command]
pub fn ai_ask( pub fn ai_ask(
app: AppHandle, app: AppHandle,
@@ -1734,17 +2133,19 @@ pub fn ai_ask(
.ai_provider .ai_provider
.clone() .clone()
.unwrap_or_else(|| "anthropic".to_string()); .unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" { let key = match provider.as_str() {
Some(String::new()) "ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
} else if provider == "openai" { "openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
config.openai_api_key.clone() "openai" => config.openai_api_key.clone(),
} else { _ => config.ai_api_key.clone(),
config.ai_api_key.clone()
} }
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?; .ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
let model = config.ai_model.clone(); let model = config.ai_model.clone();
let style = config.ai_writing_style.clone(); let style = config.ai_writing_style.clone();
let base_url = config.ollama_base_url.clone(); let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, style, base_url) (provider, key, model, style, base_url)
}; };
@@ -1798,17 +2199,21 @@ pub fn ai_ask(
// ── Helpers ── // ── Helpers ──
static ANDROID_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new(); // On mobile (Android + iOS) the OS config dir is injected at startup via the Tauri
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub fn set_android_config_dir(path: std::path::PathBuf) { pub fn set_mobile_config_dir(path: std::path::PathBuf) {
let _ = ANDROID_CONFIG_DIR.set(path); let _ = MOBILE_CONFIG_DIR.set(path);
} }
fn app_config_path() -> Result<std::path::PathBuf, String> { fn app_config_path() -> Result<std::path::PathBuf, String> {
let app_dir = if let Some(config_dir) = dirs::config_dir() { // Prefer the injected mobile dir when present (set only on mobile); fall back to
// the platform config dir on desktop.
let app_dir = if let Some(mobile_dir) = MOBILE_CONFIG_DIR.get() {
mobile_dir.join("helixnotes")
} else if let Some(config_dir) = dirs::config_dir() {
config_dir.join("helixnotes") config_dir.join("helixnotes")
} else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() {
android_dir.join("helixnotes")
} else { } else {
return Err("Config directory not available yet".to_string()); return Err("Config directory not available yet".to_string());
}; };
+27 -12
View File
@@ -4,6 +4,7 @@ mod commands;
mod history; mod history;
mod search; mod search;
mod state; mod state;
mod sync;
mod types; mod types;
mod vault; mod vault;
@@ -11,7 +12,7 @@ use state::AppState;
#[allow(unused_imports)] #[allow(unused_imports)]
use tauri::{Emitter, Manager}; use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
use tauri::{ use tauri::{
image::Image, image::Image,
menu::{MenuBuilder, MenuItemBuilder}, menu::{MenuBuilder, MenuItemBuilder},
@@ -20,14 +21,20 @@ use tauri::{
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { 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() rustls::crypto::ring::default_provider()
.install_default() .install_default()
.expect("Failed to install rustls crypto provider"); .expect("Failed to install rustls crypto provider");
let config = commands::load_app_config(); let config = commands::load_app_config();
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
let show_tray = config.show_tray_icon; 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 close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config); let app_state = AppState::new(config);
@@ -44,28 +51,28 @@ pub fn run() {
)?; )?;
} }
// On Android, set config dir from Tauri's path resolver, then reload config // On mobile, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")] #[cfg(mobile)]
{ {
if let Ok(config_dir) = app.path().config_dir() { 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() { } 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 reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| { let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded; *cfg = reloaded;
}); });
} }
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
if show_tray { if show_tray {
setup_tray(app)?; setup_tray(app)?;
} }
// Check CLI args for a .md file path on initial launch // 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 file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim(); let a = a.trim();
@@ -97,6 +104,7 @@ pub fn run() {
commands::set_font_size, commands::set_font_size,
commands::set_font_family, commands::set_font_family,
commands::set_line_height, commands::set_line_height,
commands::set_ui_scale,
commands::get_notebooks, commands::get_notebooks,
commands::count_root_notes, commands::count_root_notes,
commands::create_notebook, commands::create_notebook,
@@ -114,6 +122,10 @@ pub fn run() {
commands::get_all_tags, commands::get_all_tags,
commands::get_all_note_titles, commands::get_all_note_titles,
commands::get_graph_data, commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
commands::set_task_priority,
commands::set_task_due,
commands::search_notes, commands::search_notes,
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
@@ -152,6 +164,9 @@ pub fn run() {
commands::set_ai_settings, commands::set_ai_settings,
commands::test_ai_connection, commands::test_ai_connection,
commands::ai_ask, commands::ai_ask,
commands::set_sync_settings,
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type, commands::get_install_type,
commands::get_pending_open_file, commands::get_pending_open_file,
]) ])
@@ -229,7 +244,7 @@ pub fn run() {
}); });
}); });
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
{ {
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| { builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window // Always show/focus the main window
@@ -290,7 +305,7 @@ pub fn run() {
.expect("error while running tauri application"); .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>> { fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?; let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?; let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
+229 -18
View File
@@ -5,15 +5,163 @@ use std::fs;
use std::path::Path; use std::path::Path;
use std::sync::Mutex; use std::sync::Mutex;
use tantivy::collector::TopDocs; use tantivy::collector::TopDocs;
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
use tantivy::directory::MmapDirectory; use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")] #[cfg(mobile)]
use tantivy::directory::RamDirectory; 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::schema::*;
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
use tantivy::{Index, IndexWriter, TantivyDocument, Term}; use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir; 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 { pub struct SearchIndex {
index: Index, index: Index,
writer: Mutex<Option<IndexWriter>>, writer: Mutex<Option<IndexWriter>>,
@@ -29,29 +177,70 @@ impl SearchIndex {
pub fn new(vault_path: &str) -> Result<Self, String> { pub fn new(vault_path: &str) -> Result<Self, String> {
let mut schema_builder = Schema::builder(); let mut schema_builder = Schema::builder();
let path_field = schema_builder.add_text_field("path", STRING | STORED); let path_field = schema_builder.add_text_field("path", STRING | STORED);
let title_field = schema_builder.add_text_field("title", TEXT | STORED); // CJK-aware tokenizer for the indexed text fields (keeps Latin/English
let body_field = schema_builder.add_text_field("body", TEXT); // behaviour identical; adds substring matching for Chinese/Japanese/Korean).
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED); 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(); 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 // Desktop: use mmap directory for persistent index on disk
#[cfg(target_os = "android")] #[cfg(mobile)]
let index = { let index = {
let dir = RamDirectory::create(); let dir = RamDirectory::create();
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())? Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
}; };
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
let index = { 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())?;
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())?; fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&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())? 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; let heap_size = 15_000_000;
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
let heap_size = 50_000_000; let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?; let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
@@ -153,19 +342,40 @@ impl SearchIndex {
let searcher = reader.searcher(); let searcher = reader.searcher();
let fields = vec![self.title_field, self.body_field, self.tags_field]; let fields = vec![self.title_field, self.body_field, self.tags_field];
let terms: Vec<String> = query_str // Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
.split_whitespace() // Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
.map(|t| t.to_lowercase()) // (For pure-ASCII queries this yields the same lowercased word tokens as before.)
.collect(); 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), // For each term, OR queries across all fields, then AND all terms together.
// 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 let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter() .iter()
.map(|term| { .map(|term| {
let is_cjk_term = term.chars().any(is_cjk);
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter() .iter()
.flat_map(|&field| { .flat_map(|&field| {
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( let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)], vec![Term::from_field_text(field, term)],
)); ));
@@ -175,6 +385,7 @@ impl SearchIndex {
true, true,
)); ));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)] vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
}
}) })
.collect(); .collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries)); let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
+2
View File
@@ -10,6 +10,7 @@ pub struct AppState {
pub search_index: Mutex<Option<Arc<SearchIndex>>>, pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>, pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool, pub importing: AtomicBool,
pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>, pub pending_open_file: Mutex<Option<String>>,
} }
@@ -20,6 +21,7 @@ impl AppState {
search_index: Mutex::new(None), search_index: Mutex::new(None),
watcher: Mutex::new(None), watcher: Mutex::new(None),
importing: AtomicBool::new(false), importing: AtomicBool::new(false),
syncing: AtomicBool::new(false),
pending_open_file: Mutex::new(None), pending_open_file: Mutex::new(None),
} }
} }
+670
View File
@@ -0,0 +1,670 @@
// 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(())
}
fn put_with_dirs(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
/// the base collection itself.
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
let path = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href).ok()?.path().to_string()
} else {
href.to_string()
};
let path = path.trim_end_matches('/');
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
if rest.is_empty() {
return None;
}
Some(urlencoding::decode(rest).ok()?.into_owned())
}
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.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
},
);
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.put_with_dirs(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
},
);
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.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
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.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
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);
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)
}
+77
View File
@@ -70,9 +70,15 @@ pub struct AppConfig {
#[serde(default)] #[serde(default)]
pub line_height: Option<f64>, pub line_height: Option<f64>,
#[serde(default)] #[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub compact_notes: bool, pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)] #[serde(default)]
pub time_format: String, pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default)] #[serde(default)]
pub gpu_acceleration: bool, pub gpu_acceleration: bool,
#[serde(default)] #[serde(default)]
@@ -109,6 +115,12 @@ pub struct AppConfig {
pub openai_api_key: Option<String>, pub openai_api_key: Option<String>,
#[serde(default)] #[serde(default)]
pub ollama_base_url: Option<String>, 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")] #[serde(default = "default_ai_model")]
pub ai_model: String, pub ai_model: String,
#[serde(default)] #[serde(default)]
@@ -121,6 +133,25 @@ pub struct AppConfig {
pub close_to_tray: bool, pub close_to_tray: bool,
#[serde(default = "default_true")] #[serde(default = "default_true")]
pub enable_wiki_links: bool, pub enable_wiki_links: bool,
#[serde(default)]
pub restore_last_session: bool,
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
#[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>,
} }
fn default_true() -> bool { fn default_true() -> bool {
@@ -143,6 +174,10 @@ fn default_title_mode() -> String {
"input".to_string() "input".to_string()
} }
fn default_week_start() -> String {
"monday".to_string()
}
fn default_max_versions() -> u32 { fn default_max_versions() -> u32 {
20 20
} }
@@ -161,8 +196,11 @@ impl Default for AppConfig {
font_size: None, font_size: None,
font_family: None, font_family: None,
line_height: None, line_height: None,
ui_scale: None,
compact_notes: false, compact_notes: false,
show_note_dates: true,
time_format: "relative".to_string(), time_format: "relative".to_string(),
week_start: "monday".to_string(),
gpu_acceleration: true, gpu_acceleration: true,
autostart: false, autostart: false,
pdf_preview: false, pdf_preview: false,
@@ -181,12 +219,24 @@ impl Default for AppConfig {
ai_api_key: None, ai_api_key: None,
openai_api_key: None, openai_api_key: None,
ollama_base_url: 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_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None, ai_writing_style: None,
default_view_mode: false, default_view_mode: false,
show_tray_icon: false, show_tray_icon: false,
close_to_tray: false, close_to_tray: false,
enable_wiki_links: true, 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,
} }
} }
} }
@@ -203,6 +253,16 @@ pub struct VaultState {
pub notebook_sort_mode: String, pub notebook_sort_mode: String,
#[serde(default)] #[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>, pub notebook_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,
} }
impl Default for VaultState { impl Default for VaultState {
@@ -215,6 +275,11 @@ impl Default for VaultState {
collapsed_notebooks: Vec::new(), collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(), notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(), notebook_order: std::collections::HashMap::new(),
sort_mode: String::new(),
last_view_mode: String::new(),
last_notebook: None,
last_tag: None,
tasks_layout: String::new(),
} }
} }
} }
@@ -293,3 +358,15 @@ pub struct GraphEdge {
pub target: usize, pub target: usize,
pub bidirectional: bool, 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>,
}
+21 -21
View File
@@ -154,7 +154,7 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
(entries, note_count) (entries, note_count)
} }
fn is_hidden(path: &Path) -> bool { pub(crate) fn is_hidden(path: &Path) -> bool {
path.file_name() path.file_name()
.and_then(|n| n.to_str()) .and_then(|n| n.to_str())
.map(|n| { .map(|n| {
@@ -194,8 +194,8 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
return Err("Path does not exist".to_string()); return Err("Path does not exist".to_string());
} }
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE // On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(target_os = "android")] #[cfg(mobile)]
{ {
let mut notes = Vec::new(); let mut notes = Vec::new();
// Log what read_dir sees // Log what read_dir sees
@@ -238,12 +238,12 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
} }
} }
} }
log::info!("scan_notes: Android scan found {} notes", notes.len()); log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified)); notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes); return Ok(notes);
} }
#[cfg(not(target_os = "android"))] #[cfg(desktop)]
{ {
let md_files: Vec<PathBuf> = if notebook_path.is_some() { let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root) fs::read_dir(root)
@@ -279,7 +279,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, /// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text. /// 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> { fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB - enough for frontmatter with tags, title, pinned // Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?; let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
@@ -708,17 +708,20 @@ fn update_wikilinks_after_rename(
new_title: &str, new_title: &str,
) { ) {
let vault = Path::new(vault_path); 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 // Vault-relative path ref (without .md), normalized to forward slashes so it
let old_rel_ref = old_path.strip_prefix(&vault_prefix) // matches [[folder/note]] wikilinks on Windows (OS paths use backslashes there).
.unwrap_or(old_path) let rel_ref = |p: &str| -> String {
.strip_suffix(".md") let rel = Path::new(p)
.unwrap_or(old_path); .strip_prefix(vault)
let new_rel_ref = new_path.strip_prefix(&vault_prefix) .map(|r| r.to_string_lossy().into_owned())
.unwrap_or(new_path) .unwrap_or_else(|_| p.to_string())
.strip_suffix(".md") .replace('\\', "/");
.unwrap_or(new_path); 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. // 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, // If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
@@ -726,6 +729,7 @@ fn update_wikilinks_after_rename(
// vs. the other note with the same title. // vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault) let title_is_unique = !WalkDir::new(vault)
.into_iter() .into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
.any(|e| { .any(|e| {
let p = e.path(); let p = e.path();
@@ -735,10 +739,6 @@ fn update_wikilinks_after_rename(
if p.extension().and_then(|ext| ext.to_str()) != Some("md") { if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false; 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 // Check if this note's filename (without .md) matches the old title
p.file_stem() p.file_stem()
.and_then(|s| s.to_str()) .and_then(|s| s.to_str())
@@ -748,12 +748,12 @@ fn update_wikilinks_after_rename(
for entry in WalkDir::new(vault) for entry in WalkDir::new(vault)
.into_iter() .into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok()) .filter_map(|e| e.ok())
{ {
let path = entry.path(); let path = entry.path();
if !path.is_file() { continue; } if !path.is_file() { continue; }
let path_str = path.to_string_lossy(); 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; } if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself // Skip the renamed note itself
if *path_str == *new_path { continue; } if *path_str == *new_path { continue; }
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json", "$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes", "productName": "HelixNotes",
"version": "1.2.9", "version": "1.3.0",
"identifier": "com.helixnotes.app", "identifier": "com.helixnotes.app",
"build": { "build": {
"frontendDist": "../build", "frontendDist": "../build",
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"iOS": {
"minimumSystemVersion": "14.0"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
"windows": [ "windows": [
{ {
"label": "main", "label": "main",
"dragDropEnabled": false,
"decorations": true, "decorations": true,
"titleBarStyle": "Overlay", "titleBarStyle": "Overlay",
"hiddenTitle": true, "hiddenTitle": true,
+18
View File
@@ -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
}
]
}
}
+256 -1
View File
@@ -5,6 +5,37 @@
src: local("Inter"); src: local("Inter");
} }
/* JetBrains Mono - self-hosted (see static/fonts/jetbrains-mono/, OFL-1.1).
Used for code blocks, inline code, the raw source editor, and the "Mono" font option. */
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 400;
font-display: swap;
src: url("/fonts/jetbrains-mono/JetBrainsMono-Regular.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 400;
font-display: swap;
src: url("/fonts/jetbrains-mono/JetBrainsMono-Italic.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: normal;
font-weight: 700;
font-display: swap;
src: url("/fonts/jetbrains-mono/JetBrainsMono-Bold.woff2") format("woff2");
}
@font-face {
font-family: "JetBrains Mono";
font-style: italic;
font-weight: 700;
font-display: swap;
src: url("/fonts/jetbrains-mono/JetBrainsMono-BoldItalic.woff2") format("woff2");
}
:root { :root {
/* Light theme */ /* Light theme */
--bg-primary: #ffffff; --bg-primary: #ffffff;
@@ -32,6 +63,9 @@
--sidebar-width: 220px; --sidebar-width: 220px;
--notelist-width: 280px; --notelist-width: 280px;
--panel-resize-handle: 3px; --panel-resize-handle: 3px;
/* Monospace / code font (self-hosted JetBrains Mono, see @font-face above) */
--font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace,
monospace;
} }
:root.dark { :root.dark {
@@ -59,6 +93,208 @@
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4); --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.4);
} }
/* ── Named Themes ── */
:root[data-theme="solarized-light"] {
--bg-primary: #fdf6e3;
--bg-secondary: #eee8d5;
--bg-tertiary: #e5dfc9;
--bg-hover: #d8d0b8;
--bg-active: #ccc4aa;
--bg-editor: #fdf6e3;
--text-primary: #657b83;
--text-secondary: #839496;
--text-tertiary: #93a1a1;
--text-accent: #268bd2;
--border-color: #ddd5be;
--border-light: #eee8d5;
--accent: #268bd2;
--accent-hover: #1a7abf;
--accent-light: color-mix(in srgb, #268bd2 10%, transparent);
--danger: #dc322f;
--danger-hover: #c82929;
--success: #859900;
--warning: #b58900;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="solarized-dark"] {
--bg-primary: #002b36;
--bg-secondary: #073642;
--bg-tertiary: #0a3f4e;
--bg-hover: #094350;
--bg-active: #0e4d5c;
--bg-editor: #002b36;
--text-primary: #839496;
--text-secondary: #657b83;
--text-tertiary: #586e75;
--text-accent: #268bd2;
--border-color: #073642;
--border-light: #0a3f4e;
--accent: #268bd2;
--accent-hover: #1a7abf;
--accent-light: color-mix(in srgb, #268bd2 15%, transparent);
--danger: #dc322f;
--danger-hover: #c82929;
--success: #859900;
--warning: #b58900;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="catppuccin"] {
--bg-primary: #1e1e2e;
--bg-secondary: #181825;
--bg-tertiary: #11111b;
--bg-hover: #313244;
--bg-active: #45475a;
--bg-editor: #1e1e2e;
--text-primary: #cdd6f4;
--text-secondary: #bac2de;
--text-tertiary: #a6adc8;
--text-accent: #89b4fa;
--border-color: #313244;
--border-light: #181825;
--accent: #89b4fa;
--accent-hover: #74a8f7;
--accent-light: color-mix(in srgb, #89b4fa 12%, transparent);
--danger: #f38ba8;
--danger-hover: #eba0ac;
--success: #a6e3a1;
--warning: #f9e2af;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="nord"] {
--bg-primary: #2e3440;
--bg-secondary: #3b4252;
--bg-tertiary: #434c5e;
--bg-hover: #4c566a;
--bg-active: #5a6578;
--bg-editor: #2e3440;
--text-primary: #eceff4;
--text-secondary: #e5e9f0;
--text-tertiary: #d8dee9;
--text-accent: #81a1c1;
--border-color: #3b4252;
--border-light: #434c5e;
--accent: #81a1c1;
--accent-hover: #6d8fa9;
--accent-light: color-mix(in srgb, #81a1c1 12%, transparent);
--danger: #bf616a;
--danger-hover: #a85060;
--success: #a3be8c;
--warning: #ebcb8b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="tokyo-night"] {
--bg-primary: #1a1b26;
--bg-secondary: #16161e;
--bg-tertiary: #1f2335;
--bg-hover: #292e42;
--bg-active: #3b4261;
--bg-editor: #1a1b26;
--text-primary: #c0caf5;
--text-secondary: #a9b1d6;
--text-tertiary: #565f89;
--text-accent: #7aa2f7;
--border-color: #292e42;
--border-light: #1f2335;
--accent: #7aa2f7;
--accent-hover: #6492e7;
--accent-light: color-mix(in srgb, #7aa2f7 12%, transparent);
--danger: #f7768e;
--danger-hover: #e56474;
--success: #9ece6a;
--warning: #e0af68;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="github-light"] {
--bg-primary: #ffffff;
--bg-secondary: #f6f8fa;
--bg-tertiary: #eaeef2;
--bg-hover: #d0d7de;
--bg-active: #c6cdd5;
--bg-editor: #ffffff;
--text-primary: #1f2328;
--text-secondary: #656d76;
--text-tertiary: #8c959f;
--text-accent: #0969da;
--border-color: #d0d7de;
--border-light: #eaeef2;
--accent: #0969da;
--accent-hover: #0860c9;
--accent-light: color-mix(in srgb, #0969da 8%, transparent);
--danger: #d1242f;
--danger-hover: #c01020;
--success: #1a7f37;
--warning: #9a6700;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.08);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.12);
}
:root[data-theme="github-dark"] {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--bg-hover: #30363d;
--bg-active: #3c444d;
--bg-editor: #0d1117;
--text-primary: #e6edf3;
--text-secondary: #8d96a0;
--text-tertiary: #6e7681;
--text-accent: #58a6ff;
--border-color: #30363d;
--border-light: #21262d;
--accent: #58a6ff;
--accent-hover: #4493f8;
--accent-light: color-mix(in srgb, #58a6ff 10%, transparent);
--danger: #f85149;
--danger-hover: #e03d3d;
--success: #3fb950;
--warning: #d29922;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
:root[data-theme="dracula"] {
--bg-primary: #282a36;
--bg-secondary: #21222c;
--bg-tertiary: #191a21;
--bg-hover: #44475a;
--bg-active: #6272a4;
--bg-editor: #282a36;
--text-primary: #f8f8f2;
--text-secondary: #6272a4;
--text-tertiary: #44475a;
--text-accent: #bd93f9;
--border-color: #44475a;
--border-light: #2d2f3f;
--accent: #bd93f9;
--accent-hover: #a87ef5;
--accent-light: color-mix(in srgb, #bd93f9 12%, transparent);
--danger: #ff5555;
--danger-hover: #e54545;
--success: #50fa7b;
--warning: #f1fa8c;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4);
--shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5);
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -155,13 +391,32 @@ body.resizing .ProseMirror {
contain: strict; contain: strict;
} }
/* Skip layout/paint of off-screen blocks in very large (math-heavy) notes. */
.tiptap-wrapper.large-doc .ProseMirror > * {
content-visibility: auto;
contain-intrinsic-size: auto 2rem;
}
.tiptap-wrapper.large-doc .ProseMirror > .math-block {
contain-intrinsic-size: auto 3.5rem;
}
body.resizing { body.resizing {
user-select: none; user-select: none;
} }
/* Spinner for the sync button while a sync is running. */
.sync-spin {
animation: sync-spin 1s linear infinite;
transform-origin: 50% 50%;
}
@keyframes sync-spin {
to {
transform: rotate(360deg);
}
}
/* ── Mobile (Android/iOS) ── */ /* ── Mobile (Android/iOS) ── */
@media (max-width: 768px) { @media (max-width: 768px) {
html,
body { body {
padding-top: env(safe-area-inset-top); padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
+73 -1
View File
@@ -13,6 +13,7 @@ import type {
ImportResult, ImportResult,
BackupEntry, BackupEntry,
VersionEntry, VersionEntry,
TaskItem,
} from "./types"; } from "./types";
export async function openVault(path: string): Promise<void> { export async function openVault(path: string): Promise<void> {
@@ -43,6 +44,10 @@ export async function setLineHeight(height: number): Promise<void> {
return invoke("set_line_height", { height }); return invoke("set_line_height", { height });
} }
export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale });
}
export async function getNotebooks(): Promise<NotebookEntry[]> { export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks"); return invoke("get_notebooks");
} }
@@ -210,6 +215,7 @@ export async function setNotebookIcon(
export async function setGeneralSettings( export async function setGeneralSettings(
compactNotes: boolean, compactNotes: boolean,
timeFormat: string, timeFormat: string,
weekStart: string,
gpuAcceleration: boolean, gpuAcceleration: boolean,
autostart: boolean, autostart: boolean,
pdfPreview: boolean, pdfPreview: boolean,
@@ -221,10 +227,13 @@ export async function setGeneralSettings(
showTrayIcon: boolean, showTrayIcon: boolean,
closeToTray: boolean, closeToTray: boolean,
enableWikiLinks: boolean, enableWikiLinks: boolean,
showNoteDates: boolean,
restoreLastSession: boolean,
): Promise<void> { ): Promise<void> {
return invoke("set_general_settings", { return invoke("set_general_settings", {
compactNotes, compactNotes,
timeFormat, timeFormat,
weekStart,
gpuAcceleration, gpuAcceleration,
autostart, autostart,
pdfPreview, pdfPreview,
@@ -236,6 +245,8 @@ export async function setGeneralSettings(
showTrayIcon, showTrayIcon,
closeToTray, closeToTray,
enableWikiLinks, enableWikiLinks,
showNoteDates,
restoreLastSession,
}); });
} }
@@ -323,6 +334,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 ── // ── Version History ──
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> { export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
@@ -351,8 +417,14 @@ export async function setAiSettings(
model: string, model: string,
writingStyle: string | null, writingStyle: string | null,
baseUrl: string | null = null, baseUrl: string | null = null,
ollamaApiKey: string | null = null,
openaiCompatibleBaseUrl: string | null = null,
openaiCompatibleApiKey: string | null = null,
): Promise<void> { ): 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> { export async function testAiConnection(): Promise<void> {
+233 -37
View File
@@ -33,25 +33,39 @@
quickAccessPaths, quickAccessPaths,
tags, tags,
notes, notes,
notebooks,
rootNoteCount,
viewMode, viewMode,
sortMode,
tasksLayout,
activeTag, activeTag,
updateAvailable as globalUpdateAvailable, updateAvailable as globalUpdateAvailable,
settingsTab, settingsTab,
navHistory, navHistory,
viewerNote, viewerNote,
notebookSortMode, notebookSortMode,
notebookOrder notebookOrder,
syncState
} from '$lib/stores/app'; } from '$lib/stores/app';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/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'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; 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 sidebar: Sidebar;
let noteList: NoteList; let noteList: NoteList;
@@ -63,6 +77,11 @@
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : ''); let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false); let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null; 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 +111,27 @@
} }
} }
function syncConfigured(): boolean {
return get(appConfig)?.sync_provider === 'webdav';
}
// Auto-sync on a timer (only when configured and an interval is set).
async function checkScheduledSync() {
const config = get(appConfig);
if (config?.sync_provider !== 'webdav') return;
const mins = config.sync_interval_minutes ?? 0;
if (!mins || get(syncState).running) return;
const last = config.last_sync_time ? new Date(config.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 // Track note navigation in history stack
$effect(() => { $effect(() => {
const path = $activeNotePath; const path = $activeNotePath;
@@ -157,7 +197,12 @@
sidebar_collapsed: $sidebarCollapsed, sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks, collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode, notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder notebook_order: $notebookOrder,
sort_mode: $sortMode,
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag,
tasks_layout: $tasksLayout
}; };
try { try {
await saveVaultState(state); await saveVaultState(state);
@@ -174,14 +219,19 @@
persistState(); persistState();
} }
// 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) { function handleNoteSelected(path: string, content: string) {
// Selecting a real vault note exits viewer mode // Selecting a real vault note exits viewer mode
$viewerNote = null; $viewerNote = null;
taskNoteOpened = true;
editor?.loadNote(path, content); editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
function handleViewChanged() { function handleViewChanged() {
taskNoteOpened = false;
noteList?.refresh(); noteList?.refresh();
if (isMobile) $mobileView = 'notelist'; if (isMobile) $mobileView = 'notelist';
} }
@@ -192,6 +242,63 @@
if (isMobile) $mobileView = 'editor'; 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() { async function handleDailyNote() {
try { try {
const entry = await createDailyNote(); const entry = await createDailyNote();
@@ -361,9 +468,15 @@
} }
function applyTheme(t: string) { function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); 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'); root.classList.add('dark');
} }
} }
@@ -388,8 +501,24 @@
persistState(); persistState();
}); });
$effect(() => {
$sortMode;
$tasksLayout;
persistState();
});
$effect(() => {
$viewMode;
$activeNotebook;
$activeTag;
persistState();
});
onMount(async () => { onMount(async () => {
let lastNotePath: string | null = null; let lastNotePath: string | null = null;
let lastViewMode = '';
let lastNotebook: string | null = null;
let lastTag: string | null = null;
try { try {
const state = await loadVaultState(); const state = await loadVaultState();
$sidebarWidth = state.sidebar_width; $sidebarWidth = state.sidebar_width;
@@ -398,7 +527,12 @@
$collapsedNotebooks = state.collapsed_notebooks ?? []; $collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical'; $notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {}; $notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
lastNotePath = state.last_open_note ?? null; lastNotePath = state.last_open_note ?? null;
lastViewMode = state.last_view_mode ?? '';
lastNotebook = state.last_notebook ?? null;
lastTag = state.last_tag ?? null;
} catch (_) {} } catch (_) {}
// On mobile, prefetch last-opened note so first tap is instant // On mobile, prefetch last-opened note so first tap is instant
@@ -416,6 +550,34 @@
// Run sidebar and note list refresh in parallel // Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]); 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) // On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
if (isMobile) { if (isMobile) {
const tagMap = new Map<string, number>(); const tagMap = new Map<string, number>();
@@ -480,12 +642,44 @@
// Scheduled backup: check on startup and every 5 minutes // Scheduled backup: check on startup and every 5 minutes
checkScheduledBackup(); checkScheduledBackup();
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000); 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);
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
}));
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() && 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 config = get(appConfig);
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
}
prevDirty = d;
});
}); });
onDestroy(() => { onDestroy(() => {
unlistenFileChange?.(); unlistenFileChange?.();
unlistenOpenFile?.(); unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval); if (backupInterval) clearInterval(backupInterval);
if (syncInterval) clearInterval(syncInterval);
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
unsubDirty?.();
unlistenSync.forEach((u) => u());
}); });
</script> </script>
@@ -579,13 +773,20 @@
</svg> </svg>
</button> </button>
{/if} {/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"> <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"> <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"/> <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> </svg>
</button> </button>
{/if} {/if}
{#if $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'}> <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"> <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" /> <polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
@@ -622,12 +823,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
<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>
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
@@ -687,7 +883,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} /> <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>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
@@ -695,6 +891,14 @@
<div class="editor-panel"> <div class="editor-panel">
<Editor bind:this={editor} /> <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> </div>
</div> </div>
@@ -737,6 +941,21 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
min-width: 300px; 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 { .focus-topbar {
@@ -904,27 +1123,4 @@
pointer-events: auto; 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> </style>
+47 -1
View File
@@ -55,6 +55,46 @@
$showCommandPalette = false; $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', id: 'toggle-source',
label: 'Toggle Source/WYSIWYG Mode', label: 'Toggle Source/WYSIWYG Mode',
@@ -102,9 +142,15 @@
} }
function applyTheme(t: string) { function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); 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'); root.classList.add('dark');
} }
} }
+502 -33
View File
@@ -44,9 +44,10 @@
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import GraphView from './GraphView.svelte'; import GraphView from './GraphView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Track virtual keyboard height on mobile via visualViewport // Track virtual keyboard height on mobile via visualViewport
let keyboardHeight = $state(0); let keyboardHeight = $state(0);
@@ -59,6 +60,8 @@
let editorElement = $state<HTMLDivElement>(null!); let editorElement = $state<HTMLDivElement>(null!);
let sourceElement = $state<HTMLTextAreaElement>(null!); let sourceElement = $state<HTMLTextAreaElement>(null!);
const LARGE_DOC_CHARS = 100_000;
let isLargeDoc = $state(false);
let editor: Editor | null = null; let editor: Editor | null = null;
let editorReady = $state(false); let editorReady = $state(false);
let sourceContent = $state(''); let sourceContent = $state('');
@@ -140,6 +143,7 @@
// Version history // Version history
let showHistory = $state(false); let showHistory = $state(false);
let showGraph = $state(false); let showGraph = $state(false);
let tagMenu = $state<{ x: number; y: number } | null>(null);
let historyVersions = $state<VersionEntry[]>([]); let historyVersions = $state<VersionEntry[]>([]);
let historyPreview = $state<string | null>(null); let historyPreview = $state<string | null>(null);
let historySelected = $state<VersionEntry | null>(null); let historySelected = $state<VersionEntry | null>(null);
@@ -158,6 +162,10 @@
let slashSelectedIndex = $state(0); let slashSelectedIndex = $state(0);
let slashTablePicker = $state(false); let slashTablePicker = $state(false);
let slashTableHover = $state({ rows: 0, cols: 0 }); let slashTableHover = $state({ rows: 0, cols: 0 });
let slashColorPicker = $state(false);
let slashColorHex = $state('#4b6abf');
let slashColorInputEl = $state<HTMLInputElement | null>(null);
const colorPresets = ['#ef4444', '#f97316', '#eab308', '#22c55e', '#06b6d4', '#3b82f6', '#6366f1', '#a855f7', '#ec4899', '#64748b', '#000000', '#ffffff'];
interface SlashCommand { interface SlashCommand {
label: string; label: string;
@@ -166,6 +174,16 @@
action: () => void; action: () => void;
} }
function insertTimestamp(kind: 'date' | 'time' | 'datetime') {
if (!editor) return;
const now = new Date();
const pad = (n: number) => String(n).padStart(2, '0');
const date = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`;
const time = `${pad(now.getHours())}:${pad(now.getMinutes())}`;
const text = kind === 'date' ? date : kind === 'time' ? time : `${date} ${time}`;
editor.chain().focus().insertContent(text).run();
}
function getSlashCommands(): SlashCommand[] { function getSlashCommands(): SlashCommand[] {
return [ return [
{ label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() }, { label: 'Heading 1', aliases: ['h1', 'heading1', 'title'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h8M4 4v16M12 4v16M17 12l3-2v8"/></svg>', action: () => editor?.chain().focus().toggleHeading({ level: 1 }).run() },
@@ -182,6 +200,10 @@
{ label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() }, { label: 'Page Break', aliases: ['pagebreak', 'page', 'break', 'newpage', 'print'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="2" y1="9" x2="22" y2="9" stroke-dasharray="4 2"/><line x1="2" y1="15" x2="22" y2="15" stroke-dasharray="4 2"/><path d="M6 5v4M18 5v4M6 15v4M18 15v4"/></svg>', action: () => editor?.chain().focus().insertContent({ type: 'pageBreak' }).run() },
{ label: 'Math Block', aliases: ['math', 'latex', 'equation', 'formula', 'tex', 'katex'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h6l4 14h6"/><path d="M7 19l10-14"/></svg>', action: () => openMathInsert('block') }, { label: 'Math Block', aliases: ['math', 'latex', 'equation', 'formula', 'tex', 'katex'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h6l4 14h6"/><path d="M7 19l10-14"/></svg>', action: () => openMathInsert('block') },
{ label: 'Math Inline', aliases: ['mathinline', 'inline-math', 'imath', 'inlinemath'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8h2l3 8h2"/><path d="M8 12l8-4"/></svg>', action: () => openMathInsert('inline') }, { label: 'Math Inline', aliases: ['mathinline', 'inline-math', 'imath', 'inlinemath'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M5 8h2l3 8h2"/><path d="M8 12l8-4"/></svg>', action: () => openMathInsert('inline') },
{ label: 'Date', aliases: ['date', 'today', 'day'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>', action: () => insertTimestamp('date') },
{ label: 'Time', aliases: ['time', 'clock'], icon: '<svg width="15" height="15" 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"/><polyline points="12 6 12 12 16 14"/></svg>', action: () => insertTimestamp('time') },
{ label: 'Date & Time', aliases: ['datetime', 'now', 'timestamp', 'stamp'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>', action: () => insertTimestamp('datetime') },
{ label: 'Color', aliases: ['color', 'colour', 'hex', 'rgb', 'swatch', 'palette'], icon: '<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="13.5" cy="6.5" r=".5" fill="currentColor"/><circle cx="17.5" cy="10.5" r=".5" fill="currentColor"/><circle cx="8.5" cy="7.5" r=".5" fill="currentColor"/><circle cx="6.5" cy="12.5" r=".5" fill="currentColor"/><path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z"/></svg>', action: () => { slashColorPicker = true; } },
]; ];
} }
@@ -245,6 +267,38 @@
editor.chain().focus().insertContent({ type: nodeType, attrs: { tex: trimmed } }).run(); editor.chain().focus().insertContent({ type: nodeType, attrs: { tex: trimmed } }).run();
} }
} }
const katexCache = new Map<string, string>();
function renderKatex(tex: string, displayMode: boolean): string {
const key = (displayMode ? 'B:' : 'I:') + tex;
let html = katexCache.get(key);
if (html === undefined) {
try {
html = katex.renderToString(tex, { displayMode, throwOnError: false });
} catch {
html = `<span class="katex-error">${tex}</span>`;
}
katexCache.set(key, html);
}
return html;
}
let mathObserver: IntersectionObserver | null = null;
const mathPending = new WeakMap<Element, () => void>();
function observeMath(dom: HTMLElement, render: () => void) {
if (!mathObserver) {
const root = (editorElement?.closest('.editor-body') as Element) ?? null;
mathObserver = new IntersectionObserver((entries) => {
for (const e of entries) {
if (!e.isIntersecting) continue;
const fn = mathPending.get(e.target);
if (fn) { mathPending.delete(e.target); mathObserver!.unobserve(e.target); fn(); }
}
}, { root, rootMargin: '1000px 0px' });
}
mathPending.set(dom, render);
mathObserver.observe(dom);
}
function renderMathPreview(tex: string, displayMode: boolean): string { function renderMathPreview(tex: string, displayMode: boolean): string {
if (!tex.trim()) return ''; if (!tex.trim()) return '';
try { try {
@@ -453,7 +507,7 @@
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const tex = HTMLAttributes.tex || ''; const tex = HTMLAttributes.tex || '';
const rendered = katex.renderToString(tex, { displayMode: true, throwOnError: false }); const rendered = renderKatex(tex, true);
return ['div', { 'data-math-block': encodeURIComponent(tex), class: 'math-block', contenteditable: 'false' }, ['div', { innerHTML: rendered }]]; return ['div', { 'data-math-block': encodeURIComponent(tex), class: 'math-block', contenteditable: 'false' }, ['div', { innerHTML: rendered }]];
}, },
addNodeView() { addNodeView() {
@@ -462,14 +516,15 @@
dom.classList.add('math-block'); dom.classList.add('math-block');
dom.contentEditable = 'false'; dom.contentEditable = 'false';
dom.setAttribute('data-math-block', encodeURIComponent(node.attrs.tex)); dom.setAttribute('data-math-block', encodeURIComponent(node.attrs.tex));
dom.innerHTML = katex.renderToString(node.attrs.tex, { displayMode: true, throwOnError: false }); const render = () => { dom.innerHTML = renderKatex(node.attrs.tex, true); };
if (isLargeDoc) { dom.textContent = node.attrs.tex; observeMath(dom, render); } else { render(); }
dom.addEventListener('dblclick', (e) => { dom.addEventListener('dblclick', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const pos = typeof getPos === 'function' ? getPos() : null; const pos = typeof getPos === 'function' ? getPos() : null;
if (pos !== null && pos !== undefined) openMathEdit(pos, 'block', node.attrs.tex); if (pos !== null && pos !== undefined) openMathEdit(pos, 'block', node.attrs.tex);
}); });
return { dom }; return { dom, destroy() { mathObserver?.unobserve(dom); mathPending.delete(dom); } };
}; };
}, },
}); });
@@ -490,7 +545,7 @@
}, },
renderHTML({ HTMLAttributes }) { renderHTML({ HTMLAttributes }) {
const tex = HTMLAttributes.tex || ''; const tex = HTMLAttributes.tex || '';
const rendered = katex.renderToString(tex, { displayMode: false, throwOnError: false }); const rendered = renderKatex(tex, false);
return ['span', { 'data-math-inline': encodeURIComponent(tex), class: 'math-inline', contenteditable: 'false' }, ['span', { innerHTML: rendered }]]; return ['span', { 'data-math-inline': encodeURIComponent(tex), class: 'math-inline', contenteditable: 'false' }, ['span', { innerHTML: rendered }]];
}, },
addNodeView() { addNodeView() {
@@ -499,14 +554,15 @@
dom.classList.add('math-inline'); dom.classList.add('math-inline');
dom.contentEditable = 'false'; dom.contentEditable = 'false';
dom.setAttribute('data-math-inline', encodeURIComponent(node.attrs.tex)); dom.setAttribute('data-math-inline', encodeURIComponent(node.attrs.tex));
dom.innerHTML = katex.renderToString(node.attrs.tex, { displayMode: false, throwOnError: false }); const render = () => { dom.innerHTML = renderKatex(node.attrs.tex, false); };
if (isLargeDoc) { dom.textContent = node.attrs.tex; observeMath(dom, render); } else { render(); }
dom.addEventListener('dblclick', (e) => { dom.addEventListener('dblclick', (e) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const pos = typeof getPos === 'function' ? getPos() : null; const pos = typeof getPos === 'function' ? getPos() : null;
if (pos !== null && pos !== undefined) openMathEdit(pos, 'inline', node.attrs.tex); if (pos !== null && pos !== undefined) openMathEdit(pos, 'inline', node.attrs.tex);
}); });
return { dom }; return { dom, destroy() { mathObserver?.unobserve(dom); mathPending.delete(dom); } };
}; };
}, },
}); });
@@ -875,6 +931,60 @@
}, },
}); });
// Color swatch decorations: render a small filled square before every hex/rgb/hsl color
// literal (in normal text AND code blocks), VSCode-style. These are view-only widget
// decorations, so they never touch the document/markdown - the note stores only the plain
// color text and the swatch is re-derived on load.
const colorSwatchPluginKey = new PluginKey('colorSwatch');
const COLOR_LITERAL_RE = /#(?:[0-9a-fA-F]{8}|[0-9a-fA-F]{6}|[0-9a-fA-F]{3,4})\b|(?:rgb|rgba|hsl|hsla)\([^)\n]{1,64}\)/g;
function makeColorSwatch(color: string): HTMLElement {
const span = document.createElement('span');
span.className = 'color-swatch';
span.contentEditable = 'false';
span.style.backgroundColor = color;
return span;
}
function buildColorSwatchDecorations(doc: any): DecorationSet {
const decos: any[] = [];
doc.descendants((node: any, pos: number) => {
if (!node.isText || !node.text) return;
const text: string = node.text;
COLOR_LITERAL_RE.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = COLOR_LITERAL_RE.exec(text)) !== null) {
const color = m[0];
// Validate with the browser's own CSS parser so junk (rgb(foo), #12345) gets no swatch.
if (!CSS.supports('color', color)) continue;
const at = pos + m.index;
decos.push(Decoration.widget(at, () => makeColorSwatch(color), { side: -1, key: 'cs:' + color + '@' + at }));
}
});
return DecorationSet.create(doc, decos);
}
const ColorSwatch = Extension.create({
name: 'colorSwatch',
addProseMirrorPlugins() {
return [
new Plugin({
key: colorSwatchPluginKey,
state: {
init: (_, state) => buildColorSwatchDecorations(state.doc),
apply: (tr, old, _oldState, newState) => {
if (!tr.docChanged) return old;
return buildColorSwatchDecorations(newState.doc);
},
},
props: {
decorations(state) { return colorSwatchPluginKey.getState(state); },
},
}),
];
},
});
const MoveLineShortcuts = Extension.create({ const MoveLineShortcuts = Extension.create({
name: 'moveLineShortcuts', name: 'moveLineShortcuts',
addProseMirrorPlugins() { addProseMirrorPlugins() {
@@ -1035,6 +1145,14 @@
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run(); editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run();
return; return;
} }
// Color opens a sub-picker too
if (cmd.label === 'Color') {
slashColorPicker = true;
slashSelectedIndex = 0;
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run();
tick().then(() => slashColorInputEl?.focus());
return;
}
// Delete the slash trigger text (/ + query) // Delete the slash trigger text (/ + query)
editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run(); editor.chain().focus().deleteRange({ from: slashMenu.from, to: slashMenu.to }).run();
slashMenu = null; slashMenu = null;
@@ -1049,6 +1167,14 @@
closeSlashMenu(); closeSlashMenu();
} }
function insertColor(color: string) {
if (!editor) return;
const c = (color || '').trim();
if (!c || !CSS.supports('color', c)) { closeSlashMenu(); return; }
editor.chain().focus().insertContent(c).run();
closeSlashMenu();
}
// Track whether the user just typed a slash (vs cursor moving into existing text) // Track whether the user just typed a slash (vs cursor moving into existing text)
let slashTypedByUser = false; let slashTypedByUser = false;
@@ -1057,6 +1183,7 @@
slashSelectedIndex = 0; slashSelectedIndex = 0;
slashTablePicker = false; slashTablePicker = false;
slashTableHover = { rows: 0, cols: 0 }; slashTableHover = { rows: 0, cols: 0 };
slashColorPicker = false;
} }
$effect(() => { $effect(() => {
@@ -1079,7 +1206,7 @@
const wasSlashTyped = slashTypedByUser; const wasSlashTyped = slashTypedByUser;
slashTypedByUser = false; slashTypedByUser = false;
if (!editor) return; if (!editor) return;
if (slashTablePicker) return; // Table picker is open, don't interfere if (slashTablePicker || slashColorPicker) return; // a sub-picker is open, don't interfere
const { state } = editor; const { state } = editor;
const { selection } = state; const { selection } = state;
const resolvedFrom = selection.$from; const resolvedFrom = selection.$from;
@@ -1142,6 +1269,13 @@
}, },
handleKeyDown: (_view, event) => { handleKeyDown: (_view, event) => {
if (!slashMenu) return false; if (!slashMenu) return false;
if (slashColorPicker) {
if (event.key === 'Escape') {
event.preventDefault();
closeSlashMenu();
}
return true; // the picker's own inputs handle the rest
}
if (slashTablePicker) { if (slashTablePicker) {
if (event.key === 'Escape') { if (event.key === 'Escape') {
event.preventDefault(); event.preventDefault();
@@ -1276,7 +1410,10 @@
function wikiLinkFolderPath(entry: NoteTitleEntry): string { function wikiLinkFolderPath(entry: NoteTitleEntry): string {
const vaultRoot = $appConfig?.active_vault; const vaultRoot = $appConfig?.active_vault;
if (!vaultRoot || !entry.path) return ''; if (!vaultRoot || !entry.path) return '';
const rel = entry.path.startsWith(vaultRoot + '/') ? entry.path.slice(vaultRoot.length + 1) : entry.path; // Normalize Windows backslashes so the folder subtitle shows there too.
const path = entry.path.replace(/\\/g, '/');
const root = vaultRoot.replace(/\\/g, '/');
const rel = path.startsWith(root + '/') ? path.slice(root.length + 1) : path;
const parts = rel.split('/'); const parts = rel.split('/');
// Return parent folder(s), excluding the filename // Return parent folder(s), excluding the filename
return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : ''; return parts.length > 1 ? parts.slice(0, -1).join('/') + '/' : '';
@@ -1759,6 +1896,31 @@
} }
} }
// ── Tag editing (active note) ──
function toggleTagMenu(e: MouseEvent) {
if (tagMenu) { tagMenu = null; return; }
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const width = 240;
const x = Math.max(8, Math.min(rect.left, window.innerWidth - width - 8));
tagMenu = { x, y: rect.bottom + 4 };
}
function addActiveNoteTag(tag: string) {
if (!$activeNote) return;
const cleaned = tag.trim().toLowerCase();
if (!cleaned || $activeNote.meta.tags.includes(cleaned)) return;
$activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: [...$activeNote.meta.tags, cleaned] } };
$editorDirty = true;
autoSave();
}
function removeActiveNoteTag(tag: string) {
if (!$activeNote) return;
$activeNote = { ...$activeNote, meta: { ...$activeNote.meta, tags: $activeNote.meta.tags.filter((t) => t !== tag) } };
$editorDirty = true;
autoSave();
}
// Sync editor editable state when readOnly store changes (from titlebar or editor) // Sync editor editable state when readOnly store changes (from titlebar or editor)
$effect(() => { $effect(() => {
const ro = $readOnly; const ro = $readOnly;
@@ -1912,6 +2074,7 @@
loadedPath = path; loadedPath = path;
lastSourceMode = $sourceMode; lastSourceMode = $sourceMode;
isLoadingNote = true; isLoadingNote = true;
isLargeDoc = content.length > LARGE_DOC_CHARS;
// Apply default view mode when switching notes - but new notes always open in edit mode. // Apply default view mode when switching notes - but new notes always open in edit mode.
// Viewer mode (external file) always forces read-only. // Viewer mode (external file) always forces read-only.
const isViewer = !!get(viewerNote); const isViewer = !!get(viewerNote);
@@ -2633,9 +2796,7 @@
if (!mathBlock) { mathBlock = []; continue; } if (!mathBlock) { mathBlock = []; continue; }
const tex = mathBlock.join('\n').trim(); const tex = mathBlock.join('\n').trim();
mathBlock = null; mathBlock = null;
try { outLines.push(`<div data-math-block="${encodeURIComponent(tex)}" class="math-block"></div>`);
outLines.push(`<div data-math-block="${encodeURIComponent(tex)}" class="math-block">${katex.renderToString(tex, { displayMode: true, throwOnError: false })}</div>`);
} catch { outLines.push('$$', tex, '$$'); }
continue; continue;
} }
if (mathBlock) { mathBlock.push(line); continue; } if (mathBlock) { mathBlock.push(line); continue; }
@@ -2643,13 +2804,11 @@
const processed = line.replace(/`[^`]*`/g, m => '\x00'.repeat(m.length)); const processed = line.replace(/`[^`]*`/g, m => '\x00'.repeat(m.length));
let result = line; let result = line;
let offset = 0; let offset = 0;
for (const m of processed.matchAll(/(?<!\$)\$(?!\$)([^\n$]+?)(?<!\$)\$(?!\$)/g)) { for (const m of processed.matchAll(/(?<!\$)\$(?![\s$])([^\n$]+?)(?<!\s)\$(?!\$)(?!\d)/g)) {
const tex = m[1].trim(); const tex = m[1].trim();
try { const html = `<span data-math-inline="${encodeURIComponent(tex)}" class="math-inline"></span>`;
const html = `<span data-math-inline="${encodeURIComponent(tex)}" class="math-inline">${katex.renderToString(tex, { displayMode: false, throwOnError: false })}</span>`;
result = result.slice(0, m.index! + offset) + html + result.slice(m.index! + m[0].length + offset); result = result.slice(0, m.index! + offset) + html + result.slice(m.index! + m[0].length + offset);
offset += html.length - m[0].length; offset += html.length - m[0].length;
} catch { /* leave as-is */ }
} }
outLines.push(result); outLines.push(result);
} }
@@ -2769,6 +2928,8 @@
editor.destroy(); editor.destroy();
editor = null; editor = null;
} }
mathObserver?.disconnect();
mathObserver = null;
editorReady = false; editorReady = false;
closeSlashMenu(); closeSlashMenu();
} }
@@ -2777,6 +2938,7 @@
if (!editorElement) return; if (!editorElement) return;
destroyEditor(); destroyEditor();
isLargeDoc = content.length > LARGE_DOC_CHARS;
const html = markdownToHtml(content); const html = markdownToHtml(content);
editor = new Editor({ editor = new Editor({
@@ -2798,7 +2960,7 @@
TableRow, TableRow,
CustomTableCell, CustomTableCell,
CustomTableHeader, CustomTableHeader,
Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:') }), Link.configure({ openOnClick: false, HTMLAttributes: { class: 'editor-link' }, isAllowedUri: (url, ctx) => ctx.defaultValidate(url) || !url.startsWith('javascript:'), shouldAutoLink: (url) => /^https?:\/\//.test(url) }),
CustomImage.configure({ inline: true, HTMLAttributes: { class: 'editor-image' } }), CustomImage.configure({ inline: true, HTMLAttributes: { class: 'editor-image' } }),
Highlight.configure({ multicolor: true }), Highlight.configure({ multicolor: true }),
Typography, Typography,
@@ -2865,6 +3027,41 @@
})]; })];
} }
}), }),
Extension.create({
name: 'detailsOpenAttrSync',
addProseMirrorPlugins() {
return [new Plugin({
key: new PluginKey('detailsOpenAttrSync'),
props: {
handleDOMEvents: {
click(view, event) {
const btn = (event.target as HTMLElement)?.closest?.('button');
const detailsEl = btn && btn.parentElement?.getAttribute('data-type') === 'details'
? (btn.parentElement as HTMLElement) : null;
if (!detailsEl) return false;
// The extension's button handler already toggled the is-open class; sync
// node.attrs.open to it. Fixes the first node (pos 0), where upstream's
// `if (!pos)` guard skips persisting the attribute.
const isOpen = detailsEl.classList.contains('is-open');
const probe = detailsEl.querySelector('[data-type="detailsContent"]') ?? detailsEl;
let pos: number;
try { pos = view.posAtDOM(probe, 0); } catch { return false; }
const resolved = view.state.doc.resolve(pos);
for (let d = resolved.depth; d >= 0; d--) {
if (resolved.node(d).type.name === 'details') {
if (resolved.node(d).attrs.open !== isOpen) {
view.dispatch(view.state.tr.setNodeMarkup(resolved.before(d), undefined, { open: isOpen }));
}
break;
}
}
return false;
}
}
}
})];
}
}),
TextAlign.configure({ types: ['heading', 'paragraph'] }).extend({ TextAlign.configure({ types: ['heading', 'paragraph'] }).extend({
addKeyboardShortcuts: () => ({}), addKeyboardShortcuts: () => ({}),
}), }),
@@ -2873,6 +3070,7 @@
MoveLineShortcuts, MoveLineShortcuts,
TabIndent, TabIndent,
NoteSearchExtension, NoteSearchExtension,
ColorSwatch,
...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []), ...($appConfig?.enable_wiki_links ? [WikiLink, WikiLinkAutocomplete] : []),
], ],
content: html, content: html,
@@ -2892,7 +3090,7 @@
(view.dom as HTMLElement).focus({ preventScroll: true }); (view.dom as HTMLElement).focus({ preventScroll: true });
} }
} }
if (target.closest('[data-type="taskItem"] input[type="checkbox"]')) { if (target.closest('li[data-checked] > label')) {
const editorBody = target.closest('.editor-body') as HTMLElement | null; const editorBody = target.closest('.editor-body') as HTMLElement | null;
if (editorBody) { if (editorBody) {
const savedScroll = editorBody.scrollTop; const savedScroll = editorBody.scrollTop;
@@ -2903,6 +3101,47 @@
} }
} }
}, },
// Let task-list checkboxes be ticked in View Mode (read-only) without
// entering edit mode - e.g. a shopping list on mobile, where editing would
// pop the soft keyboard. TipTap leaves the checkbox clickable in read-only
// but reverts the toggle on `change`; we intercept the click, cancel the
// native toggle, and dispatch the attr change ourselves so it persists via
// the normal onUpdate -> autoSave path. Edit mode keeps TipTap's behaviour.
click: (view, event) => {
if (!get(readOnly)) return false;
if (get(viewerNote)) return false; // external viewer files are never saved
const target = event.target as HTMLElement;
const label = target.closest('li[data-checked] > label');
if (!label) return false;
// Cancel the native checkbox toggle (and the label->input click cascade)
// so it doesn't fight our transaction; the node-view update will set the
// visual state from the new doc value.
event.preventDefault();
const li = label.closest('li') as HTMLElement | null;
if (!li) return false;
// Probe position from the content <div> (the real contentDOM), not the <li>
// outer node-view element, so posAtDOM lands inside the task item's content.
const probe = (li.querySelector(':scope > div') as HTMLElement | null) || li;
const pos = view.posAtDOM(probe, 0);
const resolved = view.state.doc.resolve(pos);
let itemPos = -1;
let attrs: Record<string, any> | null = null;
for (let d = resolved.depth; d >= 0; d--) {
if (resolved.node(d).type.name === 'taskItem') {
itemPos = resolved.before(d);
attrs = resolved.node(d).attrs;
break;
}
}
if (itemPos < 0) {
// Fallback: pos landed just before the item rather than inside it.
const n = view.state.doc.nodeAt(pos);
if (n && n.type.name === 'taskItem') { itemPos = pos; attrs = n.attrs; }
}
if (itemPos < 0 || !attrs) return false;
view.dispatch(view.state.tr.setNodeMarkup(itemPos, undefined, { ...attrs, checked: !attrs.checked }));
return true;
},
// Prevent native text drag - it causes copy-instead-of-move in Tauri's webview. // Prevent native text drag - it causes copy-instead-of-move in Tauri's webview.
// File drops from OS are handled by Tauri's onDragDropEvent listener instead. // File drops from OS are handled by Tauri's onDragDropEvent listener instead.
dragstart: (_view, event) => { dragstart: (_view, event) => {
@@ -3366,6 +3605,11 @@
closeTextContextMenu(); closeTextContextMenu();
} }
function ctxTimestamp() {
insertTimestamp('datetime');
closeTextContextMenu();
}
function openDetailsEl(el: HTMLElement) { function openDetailsEl(el: HTMLElement) {
if (!el.classList.contains('is-open')) { if (!el.classList.contains('is-open')) {
el.classList.add('is-open'); el.classList.add('is-open');
@@ -4104,6 +4348,17 @@
</svg> </svg>
</button> </button>
<button
class="icon-btn"
class:active={!!tagMenu}
onclick={toggleTagMenu}
title="Edit tags"
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M20.59 13.41l-7.17 7.17a2 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"/>
</svg>
</button>
<button <button
class="icon-btn" class="icon-btn"
class:active={$activeNote?.meta.pinned} class:active={$activeNote?.meta.pinned}
@@ -4178,7 +4433,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} {#if $appConfig?.ai_provider}
<button <button
class="icon-btn" class="icon-btn"
onclick={openAiMenu} onclick={openAiMenu}
@@ -4216,16 +4471,14 @@
Unfiled Notes Unfiled Notes
{/if} {/if}
</span> </span>
{#if $activeNote.meta.tags?.length > 0}
<span class="meta-divider">·</span> <span class="meta-divider">·</span>
{/if} <button class="note-tags-trigger" onclick={toggleTagMenu} title="Edit tags">
{#if $activeNote.meta.tags?.length > 0} {#if $activeNote.meta.tags?.length > 0}
<span class="note-tags"> {#each $activeNote.meta.tags as tag}<span class="note-tag">#{tag}</span>{/each}
{#each $activeNote.meta.tags as tag} {:else}
<span class="note-tag">#{tag}</span> <span class="note-tags-add">+ Tags</span>
{/each}
</span>
{/if} {/if}
</button>
</div> </div>
{/if} {/if}
@@ -4312,7 +4565,7 @@
spellcheck="false" spellcheck="false"
></textarea> ></textarea>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} style={$sourceMode ? 'display:none' : ''} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }}></div>
{:else} {:else}
<!-- Desktop: conditional rendering with line numbers --> <!-- Desktop: conditional rendering with line numbers -->
{#if $sourceMode} {#if $sourceMode}
@@ -4420,7 +4673,7 @@
></textarea> ></textarea>
{:else} {:else}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="tiptap-wrapper" spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div> <div class="tiptap-wrapper" class:large-doc={isLargeDoc} spellcheck="false" bind:this={editorElement} onclick={(e) => { closeLinkContextMenu(); handleEditorClick(e); showOutline = false; }} oncontextmenu={handleEditorContextMenu}></div>
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -4602,6 +4855,13 @@
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<!-- Insert date/time -->
<button class="fmt-btn" onclick={() => insertTimestamp('datetime')} title="Insert date and time">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
</button>
<div class="fmt-sep"></div>
<!-- Undo / Redo --> <!-- Undo / Redo -->
<button class="fmt-btn" onclick={() => editor?.chain().focus().undo().run()} title="Undo"> <button class="fmt-btn" onclick={() => editor?.chain().focus().undo().run()} title="Undo">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 015.5 5.5 5.5 5.5 0 01-5.5 5.5H11"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 015.5 5.5 5.5 5.5 0 01-5.5 5.5H11"/></svg>
@@ -4610,7 +4870,7 @@
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 004 14.5 5.5 5.5 0 009.5 20H13"/></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5A5.5 5.5 0 004 14.5 5.5 5.5 0 009.5 20H13"/></svg>
</button> </button>
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} {#if $appConfig?.ai_provider}
<div class="fmt-sep"></div> <div class="fmt-sep"></div>
<button class="fmt-btn" onclick={openAiMenu} title="AI Actions"> <button class="fmt-btn" onclick={openAiMenu} title="AI Actions">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -4658,6 +4918,10 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect width="13" height="7" x="8" y="3" rx="1"/><path d="m2 9 3 3-3 3"/><rect width="13" height="7" x="8" y="14" rx="1"/></svg>
Collapsible Section Collapsible Section
</button> </button>
<button onclick={() => { insertDropdown = false; insertTimestamp('datetime'); }}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
Date &amp; Time
</button>
</div> </div>
{/if} {/if}
</div> </div>
@@ -5084,6 +5348,10 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><polyline points="10 8 14 12 10 16"/></svg>
Collapsible Section Collapsible Section
</button> </button>
<button onclick={ctxTimestamp}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 7.5V6a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2h6"/><line x1="3" y1="10" x2="21" y2="10"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="16" y1="2" x2="16" y2="6"/><circle cx="18" cy="17" r="4"/><path d="M18 15.5v1.5l1 1"/></svg>
Date &amp; Time
</button>
<div class="text-ctx-sep"></div> <div class="text-ctx-sep"></div>
<button onclick={ctxBulletList}> <button onclick={ctxBulletList}>
<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="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg> <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="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><circle cx="3.5" cy="6" r="1.5" fill="currentColor"/><circle cx="3.5" cy="12" r="1.5" fill="currentColor"/><circle cx="3.5" cy="18" r="1.5" fill="currentColor"/></svg>
@@ -5097,7 +5365,7 @@
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1.5"/><polyline points="4.5 6.5 6 8 8.5 4.5"/><line x1="13" y1="6.5" x2="21" y2="6.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/><line x1="13" y1="17.5" x2="21" y2="17.5"/></svg>
Task List Task List
</button> </button>
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)} {#if $appConfig?.ai_provider}
<div class="text-ctx-sep"></div> <div class="text-ctx-sep"></div>
<button onclick={openAiMenu}> <button onclick={openAiMenu}>
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -5284,6 +5552,35 @@
</div> </div>
{/if} {/if}
{#if tagMenu && $activeNote}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="tag-menu-overlay" onclick={() => (tagMenu = null)}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="tag-menu" style="left: {tagMenu.x}px; top: {tagMenu.y}px" onclick={(e) => e.stopPropagation()}>
{#if $activeNote.meta.tags.length > 0}
<div class="tag-menu-list">
{#each $activeNote.meta.tags as tag}
<span class="tag-menu-chip">
#{tag}
<button class="tag-menu-remove" onclick={() => removeActiveNoteTag(tag)} title="Remove tag" aria-label="Remove tag">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</span>
{/each}
</div>
{/if}
<TagSuggestInput
existing={$activeNote.meta.tags}
placeholder="Add tag..."
onsubmit={(t) => addActiveNoteTag(t)}
oncancel={() => (tagMenu = null)}
/>
</div>
</div>
{/if}
{#if codeLangDropdown} {#if codeLangDropdown}
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="code-lang-overlay" onclick={closeCodeLangDropdown}> <div class="code-lang-overlay" onclick={closeCodeLangDropdown}>
@@ -5329,6 +5626,27 @@
{slashTableHover.rows > 0 ? `${slashTableHover.rows} x ${slashTableHover.cols}` : 'Select table size'} {slashTableHover.rows > 0 ? `${slashTableHover.rows} x ${slashTableHover.cols}` : 'Select table size'}
</div> </div>
</div> </div>
{:else if slashColorPicker}
<div class="slash-color-picker">
<div class="slash-color-swatches">
{#each colorPresets as c}
<button class="slash-color-swatch" style="background: {c}" title={c} aria-label={c} onmousedown={(e) => { e.preventDefault(); insertColor(c); }}></button>
{/each}
</div>
<div class="slash-color-row">
<input type="color" class="slash-color-native" value={/^#[0-9a-fA-F]{6}$/.test(slashColorHex) ? slashColorHex : '#4b6abf'} oninput={(e) => { slashColorHex = (e.target as HTMLInputElement).value; }} title="Pick a color" />
<input
type="text"
class="slash-color-input"
bind:this={slashColorInputEl}
value={slashColorHex}
placeholder="#hex or rgb(...)"
oninput={(e) => { slashColorHex = (e.target as HTMLInputElement).value; }}
onkeydown={(e) => { if (e.key === 'Enter') { e.preventDefault(); insertColor(slashColorHex); } else if (e.key === 'Escape') { e.preventDefault(); closeSlashMenu(); } }}
/>
<button class="slash-color-insert" onmousedown={(e) => { e.preventDefault(); insertColor(slashColorHex); }}>Insert</button>
</div>
</div>
{:else if slashFiltered.length === 0} {:else if slashFiltered.length === 0}
<div class="slash-menu-empty">No matching commands</div> <div class="slash-menu-empty">No matching commands</div>
{:else} {:else}
@@ -5748,11 +6066,18 @@
user-select: none; user-select: none;
} }
.note-tags { .note-tags-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
flex-wrap: wrap; flex-wrap: wrap;
gap: 4px; gap: 4px;
border: none;
background: none;
padding: 0;
margin: 0;
font: inherit;
color: inherit;
cursor: pointer;
} }
.note-tag { .note-tag {
@@ -5764,6 +6089,17 @@
letter-spacing: 0.01em; letter-spacing: 0.01em;
} }
.note-tags-add {
font-size: 11px;
color: var(--text-tertiary);
opacity: 0.65;
}
.note-tags-trigger:hover .note-tags-add {
opacity: 1;
color: var(--accent);
}
.toolbar-actions { .toolbar-actions {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -6039,6 +6375,12 @@
position: relative; position: relative;
} }
/* Inset the scrollbar off the window's right edge so the window resize handle
(ResizeHandles in +layout) has clean space and doesn't swallow the scrollbar. */
.editor-container:not(.mobile) .editor-body {
margin-right: 8px;
}
.editor-body::-webkit-scrollbar { .editor-body::-webkit-scrollbar {
width: 8px; width: 8px;
} }
@@ -6737,6 +7079,58 @@
flex-shrink: 0; flex-shrink: 0;
} }
.tag-menu-overlay {
position: fixed;
inset: 0;
z-index: 2000;
}
.tag-menu {
position: fixed;
width: 240px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-lg);
padding: 8px;
z-index: 2001;
}
.tag-menu-list {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 8px;
}
.tag-menu-chip {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 11px;
color: var(--text-secondary);
background: var(--bg-tertiary);
padding: 2px 4px 2px 8px;
border-radius: 10px;
}
.tag-menu-remove {
display: flex;
align-items: center;
justify-content: center;
border: none;
background: none;
color: var(--text-tertiary);
cursor: pointer;
padding: 1px;
border-radius: 50%;
}
.tag-menu-remove:hover {
color: var(--danger, #e53e3e);
background: color-mix(in srgb, var(--danger, #e53e3e) 12%, transparent);
}
.code-lang-overlay { .code-lang-overlay {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -6945,12 +7339,12 @@
transition: background 0.15s, border-color 0.15s; transition: background 0.15s, border-color 0.15s;
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked) { :global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] label input[type="checkbox"]) {
background: var(--accent); background: var(--accent);
border-color: var(--accent); border-color: var(--accent);
} }
:global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li label input[type="checkbox"]:checked::after) { :global(.tiptap-wrapper .tiptap ul[data-type="taskList"] li[data-checked="true"] label input[type="checkbox"]::after) {
content: ''; content: '';
position: absolute; position: absolute;
left: 3px; left: 3px;
@@ -7951,6 +8345,81 @@
font-weight: 500; font-weight: 500;
} }
/* Color swatch preview shown before a color literal (VSCode-style decorator) */
:global(.tiptap-wrapper .tiptap .color-swatch) {
display: inline-block;
width: 0.8em;
height: 0.8em;
border-radius: 3px;
margin-right: 0.3em;
vertical-align: baseline;
border: 1px solid var(--border-color);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.15);
user-select: none;
}
/* /color slash sub-picker */
.slash-color-picker {
padding: 8px;
width: 210px;
}
.slash-color-swatches {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: 6px;
margin-bottom: 8px;
}
.slash-color-swatch {
width: 100%;
aspect-ratio: 1;
border-radius: 5px;
border: 1px solid var(--border-color);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.12);
cursor: pointer;
padding: 0;
}
.slash-color-swatch:hover {
transform: scale(1.1);
}
.slash-color-row {
display: flex;
align-items: center;
gap: 6px;
}
.slash-color-native {
width: 28px;
height: 28px;
padding: 0;
border: 1px solid var(--border-color);
border-radius: 6px;
background: none;
cursor: pointer;
flex-shrink: 0;
}
.slash-color-input {
flex: 1;
min-width: 0;
padding: 6px 8px;
border: 1px solid var(--border-color);
border-radius: 6px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
font-family: monospace;
outline: none;
}
.slash-color-insert {
padding: 6px 10px;
border: none;
border-radius: 6px;
background: var(--accent);
color: #fff;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
}
/* AI Menu */ /* AI Menu */
.ai-menu-overlay { .ai-menu-overlay {
position: fixed; position: fixed;
+5 -3
View File
@@ -8,7 +8,7 @@
onnavigate: (path: string, title: string) => void; onnavigate: (path: string, title: string) => void;
} = $props(); } = $props();
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let canvas = $state<HTMLCanvasElement>(null!); let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true); let loading = $state(true);
@@ -76,8 +76,10 @@
} }
function getFolderKey(path: string): string { function getFolderKey(path: string): string {
const vaultRoot = $appConfig?.active_vault || ''; // Normalize Windows backslashes so folder grouping/colours work there too.
const rel = vaultRoot && path.startsWith(vaultRoot + '/') ? path.slice(vaultRoot.length + 1) : path; 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('/'); const slash = rel.indexOf('/');
return slash >= 0 ? rel.slice(0, slash) : ''; return slash >= 0 ? rel.slice(0, slash) : '';
} }
+1 -1
View File
@@ -6,7 +6,7 @@
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
+74 -69
View File
@@ -34,20 +34,25 @@
getAllTags, getAllTags,
createDailyNote createDailyNote
} from '$lib/api'; } from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time'; import { formatRelativeTime, formatDate } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener'; 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';
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; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => 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(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false); let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]); let trashNotebooks = $state<TrashNotebookEntry[]>([]);
@@ -59,14 +64,15 @@
let dailyDates = $state<Set<string>>(new Set()); let dailyDates = $state<Set<string>>(new Set());
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; 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 }> { function calDays(): Array<{ day: number; date: string; current: boolean }> {
const first = new Date(calYear, calMonth, 1); const first = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0).getDate(); const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
// Monday=0 offset const startDow = (first.getDay() - weekStartsOn + 7) % 7;
let startDow = first.getDay() - 1;
if (startDow < 0) startDow = 6;
const cells: Array<{ day: number; date: string; current: boolean }> = []; 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 i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDay; d++) { for (let d = 1; d <= lastDay; d++) {
@@ -127,18 +133,18 @@
if (isMobile) { if (isMobile) {
deriveTagsFromNotes(); deriveTagsFromNotes();
} else { } else {
await refreshTags(); try { $tags = await getAllTags(); } catch (_) {}
} }
} }
let compact = $derived($appConfig?.compact_notes ?? false); 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 contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null);
let sortMenu = $state<{ x: number; y: number } | null>(null); let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null); let editingNote = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
let movePickerNote = $state<NoteEntry | null>(null); let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null); let tagEditNote = $state<NoteEntry | null>(null);
let tagEditValue = $state('');
let tagEditTags = $state<string[]>([]); let tagEditTags = $state<string[]>([]);
// Multi-select // Multi-select
@@ -280,6 +286,7 @@
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes'; if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'tasks') return 'Tasks';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -297,6 +304,7 @@
} }
export async function refresh(invalidate = false) { export async function refresh(invalidate = false) {
if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data
if (invalidate) { if (invalidate) {
noteCache.clear(); noteCache.clear();
} }
@@ -365,6 +373,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() { export async function handleCreateNote() {
if ($viewMode === 'daily') { if ($viewMode === 'daily') {
const today = new Date(); const today = new Date();
@@ -374,7 +395,19 @@
calYear = today.getFullYear(); calYear = today.getFullYear();
return; 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 { try {
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
noteCache.clear(); noteCache.clear();
@@ -500,7 +533,7 @@
function openTagEdit(note: NoteEntry) { function openTagEdit(note: NoteEntry) {
tagEditNote = note; tagEditNote = note;
tagEditTags = [...note.meta.tags]; tagEditTags = [...note.meta.tags];
tagEditValue = ''; refreshTags();
} }
async function addTagToNote(tag: string) { async function addTagToNote(tag: string) {
@@ -508,7 +541,6 @@
const cleaned = tag.trim().toLowerCase(); const cleaned = tag.trim().toLowerCase();
if (tagEditTags.includes(cleaned)) return; if (tagEditTags.includes(cleaned)) return;
tagEditTags = [...tagEditTags, cleaned]; tagEditTags = [...tagEditTags, cleaned];
tagEditValue = '';
await saveTagsForNote(tagEditNote, tagEditTags); await saveTagsForNote(tagEditNote, tagEditTags);
} }
@@ -537,7 +569,7 @@
function openBatchTagEdit() { function openBatchTagEdit() {
batchTagEdit = true; batchTagEdit = true;
tagEditValue = ''; refreshTags();
// Show tags common to ALL selected notes // Show tags common to ALL selected notes
const selectedNotes = $notes.filter(n => selectedPaths.has(n.path)); const selectedNotes = $notes.filter(n => selectedPaths.has(n.path));
if (selectedNotes.length === 0) { tagEditTags = []; return; } if (selectedNotes.length === 0) { tagEditTags = []; return; }
@@ -549,7 +581,6 @@
async function addTagToBatch(tag: string) { async function addTagToBatch(tag: string) {
if (!tag.trim()) return; if (!tag.trim()) return;
const cleaned = tag.trim().toLowerCase(); const cleaned = tag.trim().toLowerCase();
tagEditValue = '';
if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned]; if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned];
try { try {
for (const path of selectedPaths) { for (const path of selectedPaths) {
@@ -823,6 +854,7 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if $viewMode !== 'tasks'}
{#if isAndroid} {#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select"> <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"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -842,6 +874,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
@@ -906,16 +939,11 @@
</div> </div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag to all..." placeholder="Add tag to all..."
bind:value={tagEditValue} onsubmit={(t) => addTagToBatch(t)}
onkeydown={(e) => { oncancel={() => batchTagEdit = false}
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
@@ -938,6 +966,9 @@
{/if} {/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if}
{#if $viewMode === 'daily'} {#if $viewMode === 'daily'}
<div class="cal"> <div class="cal">
<div class="cal-nav"> <div class="cal-nav">
@@ -972,7 +1003,7 @@
</div> </div>
{/if} {/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"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
@@ -1111,7 +1142,7 @@
{#if getNotebookPath(note)} {#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span> <span class="note-notebook">{getNotebookPath(note)}</span>
{/if} {/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> </div>
{:else} {:else}
<div class="note-title" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}> <div class="note-title" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1131,9 +1162,9 @@
<div class="note-meta"> <div class="note-meta">
{#if getNotebookPath(note)} {#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span> <span class="note-notebook">{getNotebookPath(note)}</span>
<span class="note-meta-sep">&middot;</span> {#if showDates}<span class="note-meta-sep">&middot;</span>{/if}
{/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} {#if note.meta.tags.length > 0}
<span class="note-tags"> <span class="note-tags">
{#each note.meta.tags.slice(0, 3) as tag} {#each note.meta.tags.slice(0, 3) as tag}
@@ -1171,16 +1202,11 @@
<div class="context-sep"></div> <div class="context-sep"></div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag to all..." placeholder="Add tag to all..."
bind:value={tagEditValue} onsubmit={(t) => addTagToBatch(t)}
onkeydown={(e) => { oncancel={() => batchTagEdit = false}
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
@@ -1228,16 +1254,11 @@
<div class="context-sep"></div> <div class="context-sep"></div>
<div class="tag-edit-section"> <div class="tag-edit-section">
<div class="tag-edit-input-row"> <div class="tag-edit-input-row">
<input <TagSuggestInput
type="text" existing={tagEditTags}
class="tag-edit-input"
placeholder="Add tag..." placeholder="Add tag..."
bind:value={tagEditValue} onsubmit={(t) => addTagToNote(t)}
onkeydown={(e) => { oncancel={() => tagEditNote = null}
if (e.key === 'Enter') { e.preventDefault(); addTagToNote(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); tagEditNote = null; }
}}
autofocus
/> />
</div> </div>
{#if tagEditTags.length > 0} {#if tagEditTags.length > 0}
@@ -1820,21 +1841,6 @@
padding: 2px 8px 4px; 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 { .tag-edit-list {
max-height: 160px; max-height: 160px;
overflow-y: auto; overflow-y: auto;
@@ -2200,8 +2206,7 @@
.cal-grid { .cal-grid {
display: grid; display: grid;
grid-template-columns: repeat(7, 1fr); grid-template-columns: repeat(7, 1fr);
gap: 4px 2px; gap: 2px;
padding: 0 2px;
} }
.cal-head { .cal-head {
@@ -2213,19 +2218,18 @@
} }
.cal-cell { .cal-cell {
position: relative;
aspect-ratio: 1;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 0.72rem; font-size: 12px;
width: 28px; border: 1px solid transparent;
height: 28px; border-radius: 6px;
border-radius: 50%;
border: none;
background: none; background: none;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer; cursor: pointer;
padding: 0; padding: 0;
margin: 0 auto;
} }
.cal-cell.empty { .cal-cell.empty {
@@ -2237,6 +2241,7 @@
} }
.cal-cell.today { .cal-cell.today {
border-color: var(--accent);
color: var(--accent); color: var(--accent);
font-weight: 700; font-weight: 700;
} }
+8 -2
View File
@@ -34,10 +34,16 @@
}); });
$effect(() => { $effect(() => {
const themeValue = $theme || 'system'; const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); 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'); root.classList.add('dark');
} }
}); });
+69
View File
@@ -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>
+1 -1
View File
@@ -83,7 +83,7 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
async function openResult(result: SearchResult) { async function openResult(result: SearchResult) {
try { try {
+466 -54
View File
@@ -1,15 +1,16 @@
<script lang="ts"> <script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app'; import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection } from '$lib/api'; import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog'; import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event'; import { listen } from '@tauri-apps/api/event';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import type { ImportResult, BackupEntry } from '$lib/types'; import type { ImportResult, BackupEntry } from '$lib/types';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'updates'; type Tab = 'general' | 'editor' | 'styling' | 'import' | 'backup' | 'ai' | 'sync' | 'updates';
let activeTab = $state<Tab>('styling'); let activeTab = $state<Tab>('styling');
// Updates state // Updates state
@@ -219,33 +220,48 @@
let aiApiKey = $derived.by(() => { let aiApiKey = $derived.by(() => {
if (aiProvider === 'openai') return _openaiKey; if (aiProvider === 'openai') return _openaiKey;
if (aiProvider === 'ollama') return ''; if (aiProvider === 'ollama') return '';
if (aiProvider === 'openai_compatible') return _openaiCompatibleKey;
return _anthropicKey; return _anthropicKey;
}); });
let _anthropicKey = $state($appConfig?.ai_api_key ?? ''); let _anthropicKey = $state($appConfig?.ai_api_key ?? '');
let _openaiKey = $state($appConfig?.openai_api_key ?? ''); let _openaiKey = $state($appConfig?.openai_api_key ?? '');
let _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434'); let _ollamaBaseUrl = $state($appConfig?.ollama_base_url ?? 'http://localhost:11434');
let _ollamaApiKey = $state($appConfig?.ollama_api_key ?? '');
let _openaiCompatibleBaseUrl = $state($appConfig?.openai_compatible_base_url ?? '');
let _openaiCompatibleKey = $state($appConfig?.openai_compatible_api_key ?? '');
let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-6'); let aiModel = $state($appConfig?.ai_model ?? 'claude-sonnet-4-6');
let aiWritingStyle = $state($appConfig?.ai_writing_style ?? ''); let aiWritingStyle = $state($appConfig?.ai_writing_style ?? '');
let aiShowKey = $state(false); let aiShowKey = $state(false);
let aiShowCompatibleKey = $state(false);
let aiShowOllamaKey = $state(false);
let aiTestLoading = $state(false); let aiTestLoading = $state(false);
let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null); let aiTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
const anthropicModels = [ const anthropicModels = [
{ value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', desc: 'Balanced' }, { value: 'claude-sonnet-4-6', label: 'Claude Sonnet 4.6', desc: 'Balanced' },
{ value: 'claude-opus-4-6', label: 'Claude Opus 4.6', desc: 'Most capable' }, { value: 'claude-opus-4-8', label: 'Claude Opus 4.8', desc: 'Most capable' },
{ value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', desc: 'Fast' }, { value: 'claude-haiku-4-5-20251001', label: 'Claude Haiku 4.5', desc: 'Fast' },
]; ];
const openaiModels = [ const openaiModels = [
{ value: 'gpt-5.2', label: 'GPT-5.2', desc: 'Most capable' }, { value: 'gpt-5.5', label: 'GPT-5.5', desc: 'Most capable' },
{ value: 'gpt-5.1', label: 'GPT-5.1', desc: 'Balanced' }, { value: 'gpt-5.4', label: 'GPT-5.4', desc: 'Balanced' },
{ value: 'gpt-5-mini', label: 'GPT-5 Mini', desc: 'Fast' }, { value: 'gpt-5-mini', label: 'GPT-5 Mini', desc: 'Fast' },
{ value: 'gpt-4.1', label: 'GPT-4.1', desc: 'Previous gen' }, { value: 'gpt-5.2', label: 'GPT-5.2', desc: 'Previous gen' },
]; ];
let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels); let aiModels = $derived(aiProvider === 'openai' ? openaiModels : anthropicModels);
async function saveAiSettings() { async function saveAiSettings() {
const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null; const baseUrl = aiProvider === 'ollama' ? (_ollamaBaseUrl || null) : null;
await setAiSettings(aiProvider, aiApiKey || null, aiModel, aiWritingStyle || null, baseUrl); await setAiSettings(
aiProvider,
aiApiKey || null,
aiModel,
aiWritingStyle || null,
baseUrl,
_ollamaApiKey || null,
_openaiCompatibleBaseUrl || null,
_openaiCompatibleKey || null,
);
if ($appConfig) { if ($appConfig) {
$appConfig = { $appConfig = {
...$appConfig, ...$appConfig,
@@ -253,6 +269,9 @@
ai_api_key: _anthropicKey || null, ai_api_key: _anthropicKey || null,
openai_api_key: _openaiKey || null, openai_api_key: _openaiKey || null,
ollama_base_url: _ollamaBaseUrl || null, ollama_base_url: _ollamaBaseUrl || null,
ollama_api_key: _ollamaApiKey || null,
openai_compatible_base_url: _openaiCompatibleBaseUrl || null,
openai_compatible_api_key: _openaiCompatibleKey || null,
ai_model: aiModel, ai_model: aiModel,
ai_writing_style: aiWritingStyle || null, ai_writing_style: aiWritingStyle || null,
}; };
@@ -281,6 +300,108 @@
} }
} }
// ── WebDAV sync ──
let syncProvider = $state<string | null>($appConfig?.sync_provider ?? null);
let syncUrl = $state($appConfig?.webdav_url ?? '');
let syncUsername = $state($appConfig?.webdav_username ?? '');
let syncPassword = $state($appConfig?.webdav_password ?? '');
let syncShowPassword = $state(false);
let syncTestLoading = $state(false);
let syncTestMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncRunning = $state(false);
let syncMessage = $state<{ type: 'success' | 'error'; text: string } | null>(null);
let syncOnOpen = $state($appConfig?.sync_on_open ?? false);
let syncOnChange = $state($appConfig?.sync_on_change ?? false);
let syncIntervalMinutes = $state($appConfig?.sync_interval_minutes ?? 0);
async function saveSyncSettings() {
await setSyncSettings(syncProvider, syncUrl || null, syncUsername || null, syncPassword || null, syncOnOpen, syncOnChange, syncIntervalMinutes);
// Reflect into the global store so the top-bar button visibility and the auto-sync
// triggers update live, without needing an app restart.
if ($appConfig) $appConfig = {
...$appConfig,
sync_provider: syncProvider,
webdav_url: syncUrl || null,
webdav_username: syncUsername || null,
webdav_password: syncPassword || null,
sync_on_open: syncOnOpen,
sync_on_change: syncOnChange,
sync_interval_minutes: syncIntervalMinutes,
};
}
async function handleTestSync() {
syncTestLoading = true;
syncTestMessage = null;
const unlisten = await listen<{ success: boolean; message?: string; error?: string }>('sync-test-result', (event) => {
const data = event.payload;
syncTestMessage = data.success
? { type: 'success', text: data.message ?? 'Connection successful' }
: { type: 'error', text: data.error ?? 'Connection failed' };
syncTestLoading = false;
unlisten();
});
try {
await saveSyncSettings();
await testSyncConnection();
} catch (e) {
syncTestMessage = { type: 'error', text: String(e) };
syncTestLoading = false;
unlisten();
}
}
async function handleSyncNow() {
syncRunning = true;
syncMessage = null;
const unlisteners: Array<() => void> = [];
const cleanup = () => { unlisteners.forEach((u) => u()); };
unlisteners.push(await listen<{ success: boolean; summary?: { uploaded?: number; downloaded?: number; deleted_local?: number; deleted_remote?: number; conflicts?: number }; last_sync_time?: string }>('sync-done', (event) => {
const s = event.payload.summary ?? {};
const parts: string[] = [];
if (s.uploaded) parts.push(`${s.uploaded} uploaded`);
if (s.downloaded) parts.push(`${s.downloaded} downloaded`);
const deleted = (s.deleted_local ?? 0) + (s.deleted_remote ?? 0);
if (deleted) parts.push(`${deleted} deleted`);
if (s.conflicts) parts.push(`${s.conflicts} conflict copies`);
syncMessage = { type: 'success', text: parts.length ? `Synced: ${parts.join(', ')}.` : 'Already up to date.' };
syncRunning = false;
if ($appConfig && event.payload.last_sync_time) $appConfig = { ...$appConfig, last_sync_time: event.payload.last_sync_time };
cleanup();
}));
unlisteners.push(await listen<{ error?: string }>('sync-error', (event) => {
syncMessage = { type: 'error', text: event.payload.error ?? 'Sync failed' };
syncRunning = false;
cleanup();
}));
try {
await saveSyncSettings();
await syncNow();
} catch (e) {
syncMessage = { type: 'error', text: String(e) };
syncRunning = false;
cleanup();
}
}
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
);
const themePresets = [
{ id: 'light', label: 'Light', bg: '#ffffff', sidebar: '#f8f9fa', accent: '#5b6abf' },
{ id: 'dark', label: 'Dark', bg: '#1a1b26', sidebar: '#1f2029', accent: '#89b4fa' },
{ id: 'solarized-light', label: 'Solarized Light', bg: '#fdf6e3', sidebar: '#eee8d5', accent: '#268bd2' },
{ id: 'solarized-dark', label: 'Solarized Dark', bg: '#002b36', sidebar: '#073642', accent: '#268bd2' },
{ id: 'catppuccin', label: 'Catppuccin', bg: '#1e1e2e', sidebar: '#181825', accent: '#89b4fa' },
{ id: 'nord', label: 'Nord', bg: '#2e3440', sidebar: '#3b4252', accent: '#81a1c1' },
{ id: 'tokyo-night', label: 'Tokyo Night', bg: '#1a1b26', sidebar: '#16161e', accent: '#7aa2f7' },
{ id: 'github-light', label: 'GitHub Light', bg: '#ffffff', sidebar: '#f6f8fa', accent: '#0969da' },
{ id: 'github-dark', label: 'GitHub Dark', bg: '#0d1117', sidebar: '#161b22', accent: '#58a6ff' },
{ id: 'dracula', label: 'Dracula', bg: '#282a36', sidebar: '#21222c', accent: '#bd93f9' },
];
const accentPresets = [ const accentPresets = [
{ name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' }, { name: 'Indigo', light: '#5b6abf', dark: '#7b9bd4' },
{ name: 'Rose', light: '#e11d48', dark: '#d4768a' }, { name: 'Rose', light: '#e11d48', dark: '#d4768a' },
@@ -301,7 +422,7 @@
]; ];
const fontFamilyPresets = [ const fontFamilyPresets = [
{ name: 'System', value: 'system', stack: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }, { name: 'System', value: 'system', stack: 'system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' },
{ name: 'Inter', value: 'inter', stack: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' }, { name: 'Inter', value: 'inter', stack: '"Inter", -apple-system, BlinkMacSystemFont, sans-serif' },
{ name: 'Georgia', value: 'georgia', stack: 'Georgia, "Times New Roman", serif' }, { name: 'Georgia', value: 'georgia', stack: 'Georgia, "Times New Roman", serif' },
{ name: 'Merriweather', value: 'merriweather', stack: '"Merriweather", Georgia, serif' }, { name: 'Merriweather', value: 'merriweather', stack: '"Merriweather", Georgia, serif' },
@@ -318,14 +439,28 @@
{ label: 'Loose', value: 2.0 }, { label: 'Loose', value: 2.0 },
]; ];
const uiScalePresets = [
{ label: '80%', value: 0.8 },
{ label: '90%', value: 0.9 },
{ label: '100%', value: 1 },
{ label: '125%', value: 1.25 },
{ label: '150%', value: 1.5 },
{ label: '175%', value: 1.75 },
{ label: '200%', value: 2 },
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo'); let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let activeFontSize = $state($appConfig?.font_size ?? 14); let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system'); let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6); let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
// General settings // General settings
let compactNotes = $state($appConfig?.compact_notes ?? false); let compactNotes = $state($appConfig?.compact_notes ?? false);
let showNoteDates = $state($appConfig?.show_note_dates ?? true);
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative'); let timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
let gpuAcceleration = $state($appConfig?.gpu_acceleration ?? true); let gpuAcceleration = $state($appConfig?.gpu_acceleration ?? true);
let autostart = $state($appConfig?.autostart ?? false); let autostart = $state($appConfig?.autostart ?? false);
@@ -380,7 +515,10 @@
function saveGeneralSettings() { function saveGeneralSettings() {
if ($appConfig) { if ($appConfig) {
$appConfig.compact_notes = compactNotes; $appConfig.compact_notes = compactNotes;
$appConfig.show_note_dates = showNoteDates;
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat; $appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
$appConfig.gpu_acceleration = gpuAcceleration; $appConfig.gpu_acceleration = gpuAcceleration;
$appConfig.autostart = autostart; $appConfig.autostart = autostart;
$appConfig.pdf_preview = pdfPreview; $appConfig.pdf_preview = pdfPreview;
@@ -393,7 +531,7 @@
$appConfig.close_to_tray = closeToTray; $appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks; $appConfig.enable_wiki_links = enableWikiLinks;
} }
setGeneralSettings(compactNotes, timeFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks) setGeneralSettings(compactNotes, timeFormat, weekStart, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
.catch((e) => console.error('Failed to save general settings:', e)); .catch((e) => console.error('Failed to save general settings:', e));
} }
@@ -405,7 +543,7 @@
function applyAccent(preset: typeof accentPresets[0]) { function applyAccent(preset: typeof accentPresets[0]) {
const root = document.documentElement; const root = document.documentElement;
const isDark = $theme === 'dark' || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const color = isDark ? preset.dark : preset.light; const color = isDark ? preset.dark : preset.light;
root.style.setProperty('--accent', color); root.style.setProperty('--accent', color);
@@ -459,6 +597,21 @@
document.documentElement.style.setProperty('--editor-line-height', String(value)); document.documentElement.style.setProperty('--editor-line-height', String(value));
} }
function selectUiScale(value: number) {
activeUiScale = value;
applyUiScale(value);
if ($appConfig) $appConfig.ui_scale = value;
setUiScale(value).catch((e) => console.error('Failed to save interface scale:', e));
}
async function applyUiScale(value: number) {
try {
await getCurrentWebview().setZoom(value);
} catch (e) {
console.error('Failed to apply interface scale:', e);
}
}
function close() { function close() {
// Ensure AI settings are saved when closing (in case input wasn't blurred) // Ensure AI settings are saved when closing (in case input wasn't blurred)
// Only save if the key differs from what's already stored // Only save if the key differs from what's already stored
@@ -499,7 +652,10 @@
// Sync general settings // Sync general settings
if ($appConfig) { if ($appConfig) {
compactNotes = $appConfig.compact_notes ?? false; compactNotes = $appConfig.compact_notes ?? false;
showNoteDates = $appConfig.show_note_dates ?? true;
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative'; timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
gpuAcceleration = $appConfig.gpu_acceleration ?? true; gpuAcceleration = $appConfig.gpu_acceleration ?? true;
autostart = $appConfig.autostart ?? false; autostart = $appConfig.autostart ?? false;
pdfPreview = $appConfig.pdf_preview ?? false; pdfPreview = $appConfig.pdf_preview ?? false;
@@ -575,6 +731,12 @@
</svg> </svg>
AI AI
</button> </button>
<button class="tab-btn" class:active={activeTab === 'sync'} onclick={() => activeTab = 'sync'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<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>
Sync
</button>
<button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}> <button class="tab-btn" class:active={activeTab === 'updates'} onclick={() => activeTab = 'updates'}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/> <path d="M21 12a9 9 0 00-9-9 9.75 9.75 0 00-6.74 2.74L3 8"/><path d="M3 3v5h5"/><path d="M12 7v5l3 3"/>
@@ -597,6 +759,15 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Show dates</span>
<span class="setting-desc">Show the date next to each note in the list</span>
</span>
<button class="toggle-switch" class:on={showNoteDates} onclick={() => { showNoteDates = !showNoteDates; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -622,6 +793,14 @@
</div> </div>
</div> </div>
<div class="settings-section">
<h3>Start week on</h3>
<div class="setting-options">
<button class="option-btn" class:active={weekStart === 'monday'} onclick={() => { weekStart = 'monday'; saveGeneralSettings(); }}>Monday</button>
<button class="option-btn" class:active={weekStart === 'sunday'} onclick={() => { weekStart = 'sunday'; saveGeneralSettings(); }}>Sunday</button>
</div>
</div>
{#if isMobile} {#if isMobile}
<div class="settings-section"> <div class="settings-section">
<h3>Vault</h3> <h3>Vault</h3>
@@ -716,6 +895,15 @@
<span class="toggle-knob"></span> <span class="toggle-knob"></span>
</button> </button>
</label> </label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Restore last session on launch</span>
<span class="setting-desc">Reopen the note and folder you were last using, instead of All Notes.</span>
</span>
<button class="toggle-switch" class:on={restoreLastSession} onclick={() => { restoreLastSession = !restoreLastSession; saveGeneralSettings(); }}>
<span class="toggle-knob"></span>
</button>
</label>
</div> </div>
<div class="settings-section"> <div class="settings-section">
@@ -770,27 +958,23 @@
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Theme</h3> <h3>Theme</h3>
<div class="theme-toggle"> <div class="theme-grid">
<button class="theme-btn" class:active={$theme === 'light'} onclick={() => { $theme = 'light'; setTheme('light'); }}> {#each themePresets as preset}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <button
<circle cx="12" cy="12" r="5" /> class="theme-swatch"
<line x1="12" y1="1" x2="12" y2="3" /> class:active={$theme === preset.id}
<line x1="12" y1="21" x2="12" y2="23" /> onclick={() => { $theme = preset.id; setTheme(preset.id); }}
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" /> title={preset.label}
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" /> >
<line x1="1" y1="12" x2="3" y2="12" /> <span class="theme-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<line x1="21" y1="12" x2="23" y2="12" /> <span class="preview-sidebar"></span>
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" /> <span class="preview-main">
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" /> <span class="preview-accent-bar"></span>
</svg> </span>
Light </span>
</button> <span class="theme-label">{preset.label}</span>
<button class="theme-btn" class:active={$theme === 'dark'} onclick={() => { $theme = 'dark'; setTheme('dark'); }}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" />
</svg>
Dark
</button> </button>
{/each}
</div> </div>
</div> </div>
@@ -801,7 +985,7 @@
<button <button
class="accent-swatch" class="accent-swatch"
class:active={activeAccent === preset.name} class:active={activeAccent === preset.name}
style="--swatch-color: {$theme === 'dark' ? preset.dark : preset.light}" style="--swatch-color: {isThemeDark ? preset.dark : preset.light}"
onclick={() => selectAccent(preset)} onclick={() => selectAccent(preset)}
title={preset.name} title={preset.name}
> >
@@ -828,6 +1012,24 @@
</div> </div>
</div> </div>
{#if !isMobile}
<div class="settings-section">
<h3>Interface Scale</h3>
<div class="font-size-options">
{#each uiScalePresets as preset}
<button
class="font-size-btn"
class:active={activeUiScale === preset.value}
onclick={() => selectUiScale(preset.value)}
>
<span class="font-size-label">{preset.label}</span>
</button>
{/each}
</div>
<p class="setting-hint">Zooms the whole app: every panel, menu, and the editor. Use Default (100%) to reset.</p>
</div>
{/if}
<div class="settings-section"> <div class="settings-section">
<h3>Line Height</h3> <h3>Line Height</h3>
<div class="font-size-options"> <div class="font-size-options">
@@ -1037,16 +1239,17 @@
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Provider</h3> <h3>Provider</h3>
<div class="setting-options"> <div class="setting-options" style="flex-wrap: wrap;">
<button class="option-btn" class:active={!aiProvider} onclick={() => { if (!aiProvider) return; aiProvider = null; aiTestMessage = null; saveAiSettings(); }}>Disabled</button> <button class="option-btn" class:active={!aiProvider} onclick={() => { if (!aiProvider) return; aiProvider = null; aiTestMessage = null; saveAiSettings(); }}>Disabled</button>
<button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button> <button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button> <button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.2'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button> <button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.5'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
<button class="option-btn" class:active={aiProvider === 'openai_compatible'} onclick={() => { if (aiProvider === 'openai_compatible') return; aiProvider = 'openai_compatible'; aiModel = ''; aiTestMessage = null; saveAiSettings(); }}>Compatible</button>
</div> </div>
</div> </div>
{#if aiProvider} {#if aiProvider}
{#if aiProvider === 'ollama'} {#if aiProvider === 'ollama' || aiProvider === 'openai_compatible'}
<p class="setting-hint" style="color: var(--text-success, #4ade80); margin-top: -4px; margin-bottom: 12px;">Your data stays on your device. No text is sent to any external server.</p> <p class="setting-hint" style="color: var(--text-success, #4ade80); margin-top: -4px; margin-bottom: 12px;">Your data stays on your device. No text is sent to any external server.</p>
{:else} {:else}
<p class="setting-hint" style="color: var(--text-warning, #f59e0b); margin-top: -4px; margin-bottom: 12px;">Your selected text and note content will be sent to {aiProvider === 'openai' ? 'OpenAI' : 'Anthropic'} servers for processing.</p> <p class="setting-hint" style="color: var(--text-warning, #f59e0b); margin-top: -4px; margin-bottom: 12px;">Your selected text and note content will be sent to {aiProvider === 'openai' ? 'OpenAI' : 'Anthropic'} servers for processing.</p>
@@ -1065,6 +1268,59 @@
/> />
<p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p> <p class="setting-hint">Ollama server address. Install from <a href="https://ollama.com" target="_blank" class="ai-link">ollama.com</a></p>
</div> </div>
<div class="settings-section">
<h3>API Key <span style="font-weight: 400; text-transform: none; font-size: 11px;">(optional)</span></h3>
<div class="ai-key-row">
<input
type={aiShowOllamaKey ? 'text' : 'password'}
class="ai-key-input"
placeholder="Only required if your server requires auth"
value={_ollamaApiKey}
oninput={(e) => { _ollamaApiKey = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<button class="ai-key-toggle" onclick={() => aiShowOllamaKey = !aiShowOllamaKey} title={aiShowOllamaKey ? 'Hide' : 'Show'}>
{#if aiShowOllamaKey}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
{:else if aiProvider === 'openai_compatible'}
<div class="settings-section">
<h3>Server URL</h3>
<input
type="text"
class="ai-key-input"
placeholder="http://localhost:1234"
value={_openaiCompatibleBaseUrl}
oninput={(e) => { _openaiCompatibleBaseUrl = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<p class="setting-hint">Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, etc.). Uses the <code>v1/completions</code> endpoint.</p>
</div>
<div class="settings-section">
<h3>API Key <span style="font-weight: 400; text-transform: none; font-size: 11px;">(optional)</span></h3>
<div class="ai-key-row">
<input
type={aiShowCompatibleKey ? 'text' : 'password'}
class="ai-key-input"
placeholder="Leave empty if no auth required"
value={_openaiCompatibleKey}
oninput={(e) => { _openaiCompatibleKey = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<button class="ai-key-toggle" onclick={() => aiShowCompatibleKey = !aiShowCompatibleKey} title={aiShowCompatibleKey ? 'Hide' : 'Show'}>
{#if aiShowCompatibleKey}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
</div>
{:else} {:else}
<div class="settings-section"> <div class="settings-section">
<h3>API Key</h3> <h3>API Key</h3>
@@ -1095,16 +1351,16 @@
<div class="settings-section"> <div class="settings-section">
<h3>Model</h3> <h3>Model</h3>
{#if aiProvider === 'ollama'} {#if aiProvider === 'ollama' || aiProvider === 'openai_compatible'}
<input <input
type="text" type="text"
class="ai-key-input" class="ai-key-input"
placeholder="gemma3:4b" placeholder={aiProvider === 'ollama' ? 'gemma3:4b' : 'Enter model name'}
value={aiModel} value={aiModel}
oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }} oninput={(e) => { aiModel = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings} onblur={saveAiSettings}
/> />
<p class="setting-hint">Enter the model name as shown by <code>ollama list</code></p> <p class="setting-hint">{aiProvider === 'ollama' ? 'Enter the model name as shown by <code>ollama list</code>' : 'Enter the model name as required by your server'}</p>
{:else} {:else}
<div class="ai-model-options"> <div class="ai-model-options">
{#each aiModels as m} {#each aiModels as m}
@@ -1136,7 +1392,7 @@
<div class="settings-section"> <div class="settings-section">
<h3>Connection</h3> <h3>Connection</h3>
<button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || (aiProvider !== 'ollama' && !aiApiKey)}> <button class="import-btn" onclick={handleTestAi} disabled={aiTestLoading || (aiProvider === 'openai_compatible' && !_openaiCompatibleBaseUrl) || (aiProvider !== 'ollama' && aiProvider !== 'openai_compatible' && !aiApiKey)}>
{#if aiTestLoading} {#if aiTestLoading}
<svg class="spinner-icon" width="16" height="16" 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> <svg class="spinner-icon" width="16" height="16" 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>
Testing... Testing...
@@ -1168,6 +1424,121 @@
{/if} {/if}
</div> </div>
{:else if activeTab === 'sync'}
<div class="tab-content">
<div class="settings-section">
<h3>Provider</h3>
<div class="setting-options">
<button class="option-btn" class:active={!syncProvider} onclick={() => { syncProvider = null; syncMessage = null; syncTestMessage = null; saveSyncSettings(); }}>Disabled</button>
<button class="option-btn" class:active={syncProvider === 'webdav'} onclick={() => { syncProvider = 'webdav'; syncMessage = null; syncTestMessage = null; saveSyncSettings(); }}>WebDAV</button>
</div>
<p class="setting-hint">Sync your vault to your own WebDAV server (Nextcloud, ownCloud, a NAS).</p>
</div>
{#if syncProvider === 'webdav'}
<div class="settings-section">
<h3>Server URL</h3>
<div class="ai-key-row">
<input type="text" class="ai-key-input" placeholder="https://cloud.example.com/remote.php/dav/files/USER/HelixNotes" value={syncUrl} oninput={(e) => { syncUrl = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
</div>
<p class="setting-hint" style="overflow-x: auto;">WebDAV folder URL, e.g. <code style="white-space: nowrap;">https://your-server/remote.php/dav/files/USER/Folder</code></p>
</div>
<div class="settings-section">
<h3>Username</h3>
<div class="ai-key-row">
<input type="text" class="ai-key-input" placeholder="your username" value={syncUsername} oninput={(e) => { syncUsername = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
</div>
</div>
<div class="settings-section">
<h3>Password</h3>
<div class="ai-key-row">
<input type={syncShowPassword ? 'text' : 'password'} class="ai-key-input" placeholder="app password" value={syncPassword} oninput={(e) => { syncPassword = (e.target as HTMLInputElement).value; }} onblur={saveSyncSettings} />
<button class="ai-key-toggle" onclick={() => syncShowPassword = !syncShowPassword} title={syncShowPassword ? 'Hide' : 'Show'}>
{#if syncShowPassword}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0112 20c-7 0-11-8-11-8a18.45 18.45 0 015.06-5.94M9.9 4.24A9.12 9.12 0 0112 4c7 0 11 8 11 8a18.5 18.5 0 01-2.16 3.19m-6.72-1.07a3 3 0 11-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>
{/if}
</button>
</div>
<p class="setting-hint">Use an app password, not your main one. Stored locally on this device.</p>
</div>
<div class="settings-section">
<h3>Connection</h3>
<button class="import-btn" onclick={handleTestSync} disabled={syncTestLoading || !syncUrl}>
{#if syncTestLoading}
<svg class="spinner-icon" width="16" height="16" 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>
Testing...
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
Test Connection
{/if}
</button>
{#if syncTestMessage}
<div class="import-result {syncTestMessage.type}">
{#if syncTestMessage.type === 'success'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
{:else}
<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="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
{/if}
<span>{syncTestMessage.text}</span>
</div>
{/if}
</div>
<div class="settings-section">
<h3>Sync</h3>
<button class="import-btn" onclick={handleSyncNow} disabled={syncRunning || !syncUrl}>
{#if syncRunning}
<svg class="spinner-icon" width="16" height="16" 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>
Syncing...
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><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>
Sync Now
{/if}
</button>
{#if syncMessage}
<div class="import-result {syncMessage.type}">
{#if syncMessage.type === 'success'}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
{:else}
<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="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
{/if}
<span>{syncMessage.text}</span>
</div>
{/if}
{#if $appConfig?.last_sync_time}
<p class="setting-hint">Last sync: {new Date($appConfig.last_sync_time).toLocaleString()}</p>
{/if}
<p class="setting-hint">If a note is edited on two devices, both are kept (one as a "(conflict)" copy).</p>
</div>
<div class="settings-section">
<h3>Automatic Sync</h3>
<div class="setting-options">
{#each [{ v: 0, l: 'Off' }, { v: 5, l: '5 min' }, { v: 15, l: '15 min' }, { v: 30, l: '30 min' }, { v: 60, l: '60 min' }] as opt}
<button class="option-btn" class:active={syncIntervalMinutes === opt.v} onclick={() => { syncIntervalMinutes = opt.v; saveSyncSettings(); }}>{opt.l}</button>
{/each}
</div>
<p class="setting-hint">How often to sync in the background.</p>
<label class="setting-toggle" style="margin-top: 12px;">
<span class="setting-label">
<span class="setting-name">Sync when a note changes</span>
<span class="setting-desc">Sync shortly after you edit a note.</span>
</span>
<button class="toggle-switch" class:on={syncOnChange} onclick={() => { syncOnChange = !syncOnChange; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
<label class="setting-toggle">
<span class="setting-label">
<span class="setting-name">Sync when the vault opens</span>
<span class="setting-desc">Sync once when the app starts.</span>
</span>
<button class="toggle-switch" class:on={syncOnOpen} onclick={() => { syncOnOpen = !syncOnOpen; saveSyncSettings(); }}><span class="toggle-knob"></span></button>
</label>
</div>
{/if}
</div>
{:else if activeTab === 'updates'} {:else if activeTab === 'updates'}
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
@@ -1398,6 +1769,7 @@
.setting-hint { .setting-hint {
font-size: 13px; font-size: 13px;
color: var(--text-tertiary); color: var(--text-tertiary);
margin-top: 6px;
} }
.setting-toggle { .setting-toggle {
@@ -1496,35 +1868,75 @@
font-weight: 500; font-weight: 500;
} }
.theme-toggle { .theme-grid {
display: flex; display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px; gap: 8px;
} }
.theme-btn { .theme-swatch {
flex: 1;
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; gap: 5px;
gap: 8px; padding: 6px;
padding: 10px 16px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 10px; border-radius: 8px;
background: var(--bg-secondary); background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer; cursor: pointer;
transition: all 0.15s; transition: all 0.15s;
} }
.theme-btn:hover { .theme-swatch:hover {
background: var(--bg-hover); border-color: var(--text-tertiary);
color: var(--text-primary);
} }
.theme-btn.active { .theme-swatch.active {
border-color: var(--accent); border-color: var(--accent);
background: var(--accent-light); box-shadow: 0 0 0 2px var(--accent-light);
}
.theme-preview {
display: flex;
width: 100%;
height: 32px;
border-radius: 4px;
overflow: hidden;
background: var(--preview-bg);
border: 1px solid rgba(0,0,0,0.1);
}
.preview-sidebar {
width: 30%;
background: var(--preview-sidebar);
flex-shrink: 0;
}
.preview-main {
flex: 1;
background: var(--preview-bg);
padding: 4px;
display: flex;
flex-direction: column;
gap: 3px;
}
.preview-accent-bar {
height: 3px;
width: 60%;
background: var(--preview-accent);
border-radius: 2px;
}
.theme-label {
font-size: 10px;
color: var(--text-secondary);
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.theme-swatch.active .theme-label {
color: var(--accent); color: var(--accent);
} }
+46 -5
View File
@@ -31,7 +31,7 @@
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let editingNotebook = $state<string | null>(null); let editingNotebook = $state<string | null>(null);
let editValue = $state(''); let editValue = $state('');
@@ -150,6 +150,13 @@
onViewChanged(); onViewChanged();
} }
function selectTasks() {
$viewMode = 'tasks';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -511,6 +518,18 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<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>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'daily'} class:active={$viewMode === 'daily'}
@@ -563,10 +582,11 @@
if (showNewNotebook) { if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null; showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else { } else {
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook) ? $activeNotebook : null; // Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true; showNewNotebook = true;
} }
}} title={$viewMode === 'notebook' && $activeNotebook ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}> }} 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"> <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" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg> </svg>
@@ -576,7 +596,9 @@
{#if showNewNotebook} {#if showNewNotebook}
<div class="new-notebook-input"> <div class="new-notebook-input">
{#if newNotebookParent} {#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} {/if}
<input <input
type="text" type="text"
@@ -969,11 +991,30 @@
} }
.new-notebook-parent { .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); color: var(--text-tertiary);
font-size: inherit; font-size: inherit;
white-space: nowrap; white-space: nowrap;
flex-shrink: 0; 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 { .new-notebook-input input {
+128
View File
@@ -0,0 +1,128 @@
<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();
if (!q) return [];
const taken = new Set(existing);
const names = $tags
.map(([name]) => name)
.filter((name) => !taken.has(name) && 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>
+692
View File
@@ -0,0 +1,692 @@
<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, 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 hideCompleted = $state(true);
let sortBy = $state<'due' | 'priority' | 'note'>('due');
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; }
const filtered = $derived.by(() => {
let list = tasks.slice();
if (hideCompleted) list = list.filter((t) => !t.completed);
list.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
if (sortBy === 'priority') {
const d = prank(a.priority) - prank(b.priority);
if (d) return d;
} else if (sortBy === '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) => !hideCompleted || !t.completed));
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">
<button class="tasks-ctl" class:active={hideCompleted} onclick={() => (hideCompleted = !hideCompleted)} title="Hide completed tasks">Hide done</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={sortBy === v} onclick={() => (sortBy = 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-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;
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;
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>
+9 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window'; 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, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api';
let { onNewNote = () => {}, onDailyNote = () => {} }: { let { onNewNote = () => {}, onDailyNote = () => {} }: {
onNewNote?: () => void; onNewNote?: () => void;
@@ -111,6 +112,13 @@
<line x1="3" y1="10" x2="21" y2="10" /> <line x1="3" y1="10" x2="21" y2="10" />
</svg> </svg>
</button> </button>
{#if $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)`}> <button class="new-note-btn" onclick={onNewNote} title={`New Note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none"> <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" /> <path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
+32 -8
View File
@@ -1,20 +1,22 @@
<script lang="ts"> <script lang="ts">
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, getAppConfig } from '$lib/api'; import { openVault, getAppConfig } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app'; import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types'; import type { VaultConfig } from '$lib/types';
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMobile = /android|ios/i.test(navigator.userAgent);
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []); let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
let loading = $state(false); let loading = $state(false);
let error = $state(''); let error = $state('');
let vaultName = $state('HelixNotes'); let vaultName = $state('HelixNotes');
let hasPermission = $state(!isMobile); let hasPermission = $state(!isAndroid);
let selectedLocation = $state('Documents'); let selectedLocation = $state('Documents');
let customPath = $state(''); let customPath = $state('');
let iosBasePath = $state('');
const storageLocations = [ const storageLocations = [
{ label: 'Documents', path: '/storage/emulated/0/Documents' }, { label: 'Documents', path: '/storage/emulated/0/Documents' },
@@ -22,7 +24,7 @@
{ label: 'Internal Storage', path: '/storage/emulated/0' }, { label: 'Internal Storage', path: '/storage/emulated/0' },
]; ];
if (isMobile) { if (isAndroid) {
checkPermission(); checkPermission();
setTimeout(checkPermission, 500); setTimeout(checkPermission, 500);
(window as any).__storagePermissionGranted = () => { hasPermission = true; }; (window as any).__storagePermissionGranted = () => { hasPermission = true; };
@@ -30,6 +32,10 @@
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300); 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() { function checkPermission() {
try { try {
@@ -56,10 +62,17 @@
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents'; 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() { 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); await openSelectedVault(fullPath);
} else { } else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' }); const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
@@ -73,7 +86,7 @@
loading = true; loading = true;
error = ''; error = '';
try { try {
if (isMobile) { if (isAndroid) {
try { try {
const bridge = (window as any).Android; const bridge = (window as any).Android;
bridge?.prepareVaultDir?.(path); bridge?.prepareVaultDir?.(path);
@@ -84,7 +97,12 @@
$appConfig = config; $appConfig = config;
$vaultReady = true; $vaultReady = true;
} catch (e) { } 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 { } finally {
loading = false; loading = false;
} }
@@ -118,15 +136,20 @@
<h1>HelixNotes</h1> <h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p> <p class="subtitle">Local markdown notes</p>
{#if isMobile} {#if isMobile}
{#if isIOS}
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone &rarr; HelixNotes".</p>
{:else}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p> <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}> <button class="btn-permission" onclick={requestPermission}>
Grant File Access Grant File Access
</button> </button>
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p> <p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
{/if} {/if}
{#if isAndroid}
<div class="location-selector"> <div class="location-selector">
<label class="location-label">Location</label> <label class="location-label">Location</label>
<div class="location-options"> <div class="location-options">
@@ -147,6 +170,7 @@
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." /> <input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
{/if} {/if}
</div> </div>
{/if}
<div class="vault-name-input"> <div class="vault-name-input">
<label for="vault-name">Vault name</label> <label for="vault-name">Vault name</label>
+7
View File
@@ -0,0 +1,7 @@
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user
// agent (not the literal "ios"), so they must be matched explicitly.
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS;
+5
View File
@@ -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';
+8
View File
@@ -16,6 +16,7 @@ export const vaultReady = writable(false);
// UI state // UI state
export const viewMode = writable<ViewMode>("all"); export const viewMode = writable<ViewMode>("all");
export const sortMode = writable<SortMode>("modified"); export const sortMode = writable<SortMode>("modified");
export const tasksLayout = writable<"list" | "calendar">("list");
export const sidebarCollapsed = writable(false); export const sidebarCollapsed = writable(false);
export const sidebarWidth = writable(220); export const sidebarWidth = writable(220);
export const notelistWidth = writable(280); export const notelistWidth = writable(280);
@@ -67,6 +68,13 @@ export const readOnly = writable(false);
// Theme // Theme
export const theme = writable<string>("system"); export const theme = writable<string>("system");
// 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 // Update state
export const updateAvailable = writable<{ export const updateAvailable = writable<{
version: string; version: string;
+35 -1
View File
@@ -54,14 +54,18 @@ export interface AppConfig {
font_size: number | null; font_size: number | null;
font_family: string | null; font_family: string | null;
line_height: number | null; line_height: number | null;
ui_scale: number | null;
compact_notes: boolean; compact_notes: boolean;
show_note_dates: boolean;
time_format: string; time_format: string;
week_start: string;
gpu_acceleration: boolean; gpu_acceleration: boolean;
autostart: boolean; autostart: boolean;
pdf_preview: boolean; pdf_preview: boolean;
pdf_height: number; pdf_height: number;
title_mode: string; title_mode: string;
hide_title_in_body: boolean; hide_title_in_body: boolean;
show_line_numbers: boolean;
backup_enabled: boolean; backup_enabled: boolean;
backup_frequency: string; backup_frequency: string;
backup_max_count: number; backup_max_count: number;
@@ -73,11 +77,24 @@ export interface AppConfig {
ai_api_key: string | null; ai_api_key: string | null;
openai_api_key: string | null; openai_api_key: string | null;
ollama_base_url: 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_model: string;
ai_writing_style: string | null; ai_writing_style: string | null;
default_view_mode: boolean; default_view_mode: boolean;
show_tray_icon: boolean; show_tray_icon: boolean;
close_to_tray: boolean;
enable_wiki_links: 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;
} }
export interface VaultState { export interface VaultState {
@@ -88,6 +105,11 @@ export interface VaultState {
collapsed_notebooks: string[]; collapsed_notebooks: string[];
notebook_sort_mode?: string; notebook_sort_mode?: string;
notebook_order?: Record<string, number>; notebook_order?: Record<string, number>;
sort_mode?: string;
last_view_mode?: string;
last_notebook?: string | null;
last_tag?: string | null;
tasks_layout?: string;
} }
export interface SearchResult { export interface SearchResult {
@@ -146,4 +168,16 @@ export type ViewMode =
| "trash" | "trash"
| "search" | "search"
| "quickaccess" | "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;
}
+35 -7
View File
@@ -4,23 +4,30 @@
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app'; import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api'; import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props(); let { children } = $props();
const isMobile = /android|ios/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Reactively apply theme class to <html> whenever $theme changes // Reactively apply theme class to <html> whenever $theme changes
$effect(() => { $effect(() => {
const t = $theme; applyTheme($theme);
});
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const root = document.documentElement; const root = document.documentElement;
root.classList.remove('dark'); root.classList.remove('dark');
if ( root.removeAttribute('data-theme');
t === 'dark' || if (namedThemes.includes(t)) {
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches) 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'); root.classList.add('dark');
} }
}); }
function normalizePath(p: string): string { function normalizePath(p: string): string {
const parts = p.split('/'); const parts = p.split('/');
@@ -160,6 +167,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> </script>
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} /> <svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
@@ -169,3 +195,5 @@
</svelte:head> </svelte:head>
{@render children()} {@render children()}
<ResizeHandles />
+18 -2
View File
@@ -2,6 +2,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app'; import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault } from '$lib/api'; import { getAppConfig, openVault } from '$lib/api';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte'; import NoteWindow from '$lib/components/NoteWindow.svelte';
@@ -25,8 +26,14 @@
// Apply theme immediately to prevent flash // Apply theme immediately to prevent flash
const themeValue = config.theme || 'system'; const themeValue = config.theme || 'system';
const root = document.documentElement; const root = document.documentElement;
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
root.classList.remove('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'); root.classList.add('dark');
} }
@@ -66,7 +73,7 @@
const preset = accentPresets[config.accent_color]; const preset = accentPresets[config.accent_color];
if (preset) { if (preset) {
const themeVal = config.theme || 'light'; const themeVal = config.theme || 'light';
const isDark = themeVal === 'dark' || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const color = isDark ? preset.dark : preset.light; const color = isDark ? preset.dark : preset.light;
const root = document.documentElement; const root = document.documentElement;
root.style.setProperty('--accent', color); root.style.setProperty('--accent', color);
@@ -78,6 +85,15 @@
} }
} }
// 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);
}
}
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
try { try {
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.