Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef | ||
|
|
000d68a0e7 | ||
|
|
d8c391a480 | ||
|
|
459af56564 | ||
|
|
9d82f2aed7 | ||
|
|
4fb99c7e39 | ||
|
|
92e8422cb0 | ||
|
|
5defddb35c | ||
|
|
dc19976568 | ||
|
|
bd59f6dbee | ||
|
|
26fa22afaa | ||
|
|
e2702bdd51 | ||
|
|
0d0ecee06e | ||
|
|
6115ad4ab6 | ||
|
|
a400eeefed | ||
|
|
f19ddee17d | ||
|
|
393505e1ac | ||
|
|
b87e8b8a38 | ||
|
|
5b62302b3a | ||
|
|
ee0ffdf121 | ||
|
|
89eea8e71b | ||
|
|
f27512c7ee | ||
|
|
eafd2b0a24 | ||
|
|
aeafd55491 | ||
|
|
ccc5e3b7c6 | ||
|
|
a28e514ed4 | ||
|
|
7c94370e48 | ||
|
|
ab39fc2ed6 | ||
|
|
621e2b31e0 | ||
|
|
ca3900815b | ||
|
|
b36ce9ed5b | ||
|
|
59677577ec | ||
|
|
71a0b9c0a0 | ||
|
|
3876b7db96 | ||
|
|
0436136533 | ||
|
|
139ecc8176 | ||
|
|
4b82d51f6c | ||
|
|
8ceb44df78 | ||
|
|
3b3e7fbbe6 | ||
|
|
7cbd112585 | ||
|
|
d275b0e783 | ||
|
|
fedbdfd2a1 | ||
|
|
7f2aae39d1 | ||
|
|
90f6f315f9 | ||
|
|
9c092e90f1 | ||
|
|
ed2fc87e86 | ||
|
|
7624c42e02 | ||
|
|
d46012b650 |
@@ -1,5 +1,10 @@
|
||||
node_modules
|
||||
|
||||
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
@@ -8,9 +13,20 @@ node_modules
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# Nix
|
||||
/result
|
||||
|
||||
# Tauri
|
||||
/src-tauri/target
|
||||
/src-tauri/gen
|
||||
|
||||
# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested
|
||||
# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out.
|
||||
/src-tauri/gen/*
|
||||
!/src-tauri/gen/android/
|
||||
|
||||
# Never commit signing secrets (nested ignore misses keystore.properties)
|
||||
/src-tauri/gen/android/keystore.properties
|
||||
/src-tauri/gen/android/key.properties
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download (v1.2.6)
|
||||
## Download (v1.3.0)
|
||||
|
||||
### Linux
|
||||
|
||||
@@ -26,25 +26,89 @@ yay -S helixnotes-appimage-bin
|
||||
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
|
||||
```
|
||||
|
||||
#### Solus (EOPKG)
|
||||
|
||||
```bash
|
||||
sudo eopkg it helixnotes
|
||||
```
|
||||
|
||||
#### NixOS
|
||||
|
||||
<details>
|
||||
<summary>flake.nix</summary>
|
||||
|
||||
```nix
|
||||
{
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
helix-notes = {
|
||||
url = "git+https://codeberg.org/ArkHost/HelixNotes";
|
||||
# inputs.nixpkgs.follows = "nixpkgs";
|
||||
}
|
||||
};
|
||||
|
||||
outputs = {
|
||||
nixpkgs,
|
||||
helix-notes,
|
||||
...
|
||||
}: let
|
||||
system = "x86_64-linux";
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
in {
|
||||
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
|
||||
system = system;
|
||||
specialArgs = { inherit helix-notes; };
|
||||
|
||||
modules = [
|
||||
/path/to/configuration.nix
|
||||
];
|
||||
};
|
||||
};
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
|
||||
<details>
|
||||
<summary>configuration.nix</summary>
|
||||
|
||||
```nix
|
||||
{
|
||||
config,
|
||||
lib,
|
||||
pkgs,
|
||||
helix-notes,
|
||||
...
|
||||
}:
|
||||
{
|
||||
users.users.<USERNAME> = {
|
||||
packages = with pkgs; [
|
||||
(helix-notes.packages.${pkgs.stdenv.hostPlatform.system}.default)
|
||||
];
|
||||
};
|
||||
}
|
||||
```
|
||||
</details>
|
||||
|
||||
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
|
||||
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.2.6/HelixNotes_1.2.6_amd64.AppImage)
|
||||
[Download AppImage](https://download.helixnotes.com/releases/v1.3.0/HelixNotes_1.3.0_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.6/HelixNotes_1.2.6_amd64.deb) (Ubuntu 22.04+)
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_amd64.deb) (Ubuntu 22.04+)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.6/HelixNotes_1.2.6_x64-setup.exe) (Windows 10/11)
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.6/HelixNotes_1.2.6_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.6/HelixNotes_1.2.6_android.apk)
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_android.apk)
|
||||
|
||||
---
|
||||
|
||||
@@ -53,15 +117,23 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
|
||||
## Features
|
||||
|
||||
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
||||
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
|
||||
- `[[Wiki-links]]` and graph view
|
||||
- Full-text search (Tantivy)
|
||||
- Outline panel, daily notes with calendar view, tags, drag-and-drop
|
||||
- Math (KaTeX), PDF preview, Obsidian import
|
||||
- AI writing tools (Ollama / Anthropic / OpenAI)
|
||||
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
|
||||
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
|
||||
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
||||
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
||||
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
|
||||
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
|
||||
- Manual notebook sorting (drag to reorder above, into, or below)
|
||||
- External `.md` viewer mode with import-to-vault flow
|
||||
- PDF preview, Obsidian import, "Show in File Manager"
|
||||
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
|
||||
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
|
||||
- Version history with diffs, automatic backups
|
||||
- Multi-window, file associations, focus mode, view mode
|
||||
- Themes, accent colors, fonts
|
||||
- Local files, no cloud
|
||||
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
|
||||
- Local plain-text files, no company cloud
|
||||
|
||||
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
|
||||
|
||||
@@ -96,9 +168,10 @@ pnpm tauri build
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
- Internal note links ([[wiki-links]] open the linked note)
|
||||
- Mobile UI improvements
|
||||
- Window state persistence (desktop)
|
||||
@@ -1,12 +0,0 @@
|
||||
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
|
||||
|
||||
Features:
|
||||
- Markdown editor with toolbar, slash commands, and code highlighting
|
||||
- [[Wiki-links]] and graph view
|
||||
- Full-text search
|
||||
- Outline panel, daily notes, tags
|
||||
- Math (KaTeX) and PDF preview
|
||||
- AI writing tools (Ollama, Anthropic, OpenAI)
|
||||
- Version history with diffs
|
||||
- Obsidian import
|
||||
- Themes, accent colors, fonts
|
||||
@@ -1 +0,0 @@
|
||||
Local markdown note-taking app. No cloud, no lock-in.
|
||||
@@ -1 +0,0 @@
|
||||
HelixNotes
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
description = "HelixNotes (local Markdown notes app) with Nix flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-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;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.2.7",
|
||||
"version": "1.3.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -65,6 +65,7 @@
|
||||
"markdown-it-mark": "^4.0.0",
|
||||
"markdown-it-sub": "^2.0.0",
|
||||
"markdown-it-sup": "^2.0.0",
|
||||
"markdown-it-task-lists": "^2.1.1"
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"mermaid": "^11.14.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "helixnotes"
|
||||
version = "1.2.7"
|
||||
version = "1.3.1"
|
||||
dependencies = [
|
||||
"arboard",
|
||||
"chrono",
|
||||
@@ -1861,6 +1861,7 @@ dependencies = [
|
||||
"log",
|
||||
"notify",
|
||||
"png 0.17.16",
|
||||
"quick-xml 0.36.2",
|
||||
"rayon",
|
||||
"regex",
|
||||
"reqwest 0.12.28",
|
||||
@@ -1868,6 +1869,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
"sha2",
|
||||
"sys-locale",
|
||||
"tantivy",
|
||||
"tauri",
|
||||
@@ -1880,6 +1882,7 @@ dependencies = [
|
||||
"tauri-plugin-updater",
|
||||
"tauri-plugin-window-state",
|
||||
"tokio",
|
||||
"urlencoding",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"zip 2.4.2",
|
||||
@@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -3612,6 +3615,15 @@ version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.36.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
@@ -5911,6 +5923,12 @@ dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "urlpattern"
|
||||
version = "0.3.0"
|
||||
@@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quick-xml",
|
||||
"quick-xml 0.38.4",
|
||||
"quote",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.2.7"
|
||||
version = "1.3.1"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -38,8 +38,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
|
||||
futures = "0.3"
|
||||
rayon = "1"
|
||||
quick-xml = "0.36"
|
||||
urlencoding = "2"
|
||||
sha2 = "0.10"
|
||||
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||
[target.'cfg(not(target_os = "android"))'.dependencies]
|
||||
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"core:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"updater:default",
|
||||
"window-state:default"
|
||||
"window-state:default",
|
||||
"core:webview:allow-set-webview-zoom"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
# EditorConfig is awesome: https://EditorConfig.org
|
||||
|
||||
# top-most EditorConfig file
|
||||
root = true
|
||||
|
||||
[*]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
charset = utf-8
|
||||
trim_trailing_whitespace = false
|
||||
insert_final_newline = false
|
||||
@@ -0,0 +1,19 @@
|
||||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
/.idea/caches
|
||||
/.idea/libraries
|
||||
/.idea/modules.xml
|
||||
/.idea/workspace.xml
|
||||
/.idea/navEditor.xml
|
||||
/.idea/assetWizardSettings.xml
|
||||
.DS_Store
|
||||
build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
||||
key.properties
|
||||
|
||||
/.tauri
|
||||
/tauri.settings.gradle
|
||||
@@ -0,0 +1,6 @@
|
||||
/src/main/java/com/helixnotes/app/generated
|
||||
/src/main/jniLibs/**/*.so
|
||||
/src/main/assets/tauri.conf.json
|
||||
/tauri.build.gradle.kts
|
||||
/proguard-tauri.pro
|
||||
/tauri.properties
|
||||
@@ -0,0 +1,92 @@
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("rust")
|
||||
}
|
||||
|
||||
val tauriProperties = Properties().apply {
|
||||
val propFile = file("tauri.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
val keystoreProperties = Properties().apply {
|
||||
val propFile = rootProject.file("keystore.properties")
|
||||
if (propFile.exists()) {
|
||||
propFile.inputStream().use { load(it) }
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdk = 36
|
||||
namespace = "com.helixnotes.app"
|
||||
|
||||
if (keystoreProperties.containsKey("storeFile")) {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file(keystoreProperties["storeFile"] as String)
|
||||
storePassword = keystoreProperties["storePassword"] as String
|
||||
keyAlias = keystoreProperties["keyAlias"] as String
|
||||
keyPassword = keystoreProperties["keyPassword"] as String
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "false"
|
||||
applicationId = "com.helixnotes.app"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
|
||||
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
|
||||
}
|
||||
buildTypes {
|
||||
getByName("debug") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
isDebuggable = true
|
||||
isJniDebuggable = true
|
||||
isMinifyEnabled = false
|
||||
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86/*.so")
|
||||
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
|
||||
}
|
||||
}
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
.toList().toTypedArray()
|
||||
)
|
||||
if (signingConfigs.names.contains("release")) {
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
}
|
||||
}
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "1.8"
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
}
|
||||
}
|
||||
|
||||
rust {
|
||||
rootDirRel = "../../../"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.webkit:webkit:1.14.0")
|
||||
implementation("androidx.appcompat:appcompat:1.7.1")
|
||||
implementation("androidx.activity:activity-ktx:1.10.1")
|
||||
implementation("com.google.android.material:material:1.12.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.4")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
|
||||
}
|
||||
|
||||
apply(from = "tauri.build.gradle.kts")
|
||||
@@ -0,0 +1,22 @@
|
||||
# Add project specific ProGuard rules here.
|
||||
# You can control the set of applied configuration files using the
|
||||
# proguardFiles setting in build.gradle.
|
||||
#
|
||||
# For more details, see
|
||||
# http://developer.android.com/guide/developing/tools/proguard.html
|
||||
|
||||
# Preserve JS interface annotations
|
||||
-keepattributes JavascriptInterface
|
||||
|
||||
# Keep our custom WebView JS bridge and all its public methods
|
||||
-keep class com.helixnotes.app.StorageBridge {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Keep MainActivity public methods (called from StorageBridge)
|
||||
-keep class com.helixnotes.app.MainActivity {
|
||||
public *;
|
||||
}
|
||||
|
||||
# Ignore missing java.beans classes (referenced by Jackson but not available on Android)
|
||||
-dontwarn java.beans.**
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
||||
<application
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@style/Theme.helixnotes"
|
||||
android:usesCleartextTraffic="${usesCleartextTraffic}">
|
||||
<activity
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
<!-- AndroidTV support -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,157 @@
|
||||
package com.helixnotes.app
|
||||
|
||||
import android.Manifest
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Environment
|
||||
import android.provider.Settings
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.app.ActivityCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import java.io.File
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private var webView: WebView? = null
|
||||
|
||||
companion object {
|
||||
private const val STORAGE_PERMISSION_REQUEST_CODE = 1001
|
||||
}
|
||||
|
||||
override fun onWebViewCreate(webView: WebView) {
|
||||
super.onWebViewCreate(webView)
|
||||
this.webView = webView
|
||||
webView.addJavascriptInterface(StorageBridge(this), "Android")
|
||||
// Render text at the size CSS specifies, ignoring the device's system font-size
|
||||
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
|
||||
webView.settings.textZoom = 100
|
||||
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
|
||||
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
|
||||
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
|
||||
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (hasStoragePermission()) {
|
||||
webView?.evaluateJavascript(
|
||||
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun hasStoragePermission(): Boolean {
|
||||
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
// Android 11+ (API 30+): "All files access" special permission.
|
||||
Environment.isExternalStorageManager()
|
||||
} else {
|
||||
// Android 7-10 (API 24-29): legacy runtime storage permission.
|
||||
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
}
|
||||
|
||||
fun requestStoragePermission() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||
if (Environment.isExternalStorageManager()) return
|
||||
try {
|
||||
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
|
||||
intent.data = Uri.parse("package:$packageName")
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
|
||||
startActivity(fallback)
|
||||
} catch (_: Exception) {
|
||||
val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
|
||||
details.data = Uri.parse("package:$packageName")
|
||||
startActivity(details)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Android 7-10: request the legacy runtime permission directly.
|
||||
ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(
|
||||
Manifest.permission.READ_EXTERNAL_STORAGE,
|
||||
Manifest.permission.WRITE_EXTERNAL_STORAGE
|
||||
),
|
||||
STORAGE_PERMISSION_REQUEST_CODE
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<out String>,
|
||||
grantResults: IntArray
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) {
|
||||
webView?.evaluateJavascript(
|
||||
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
|
||||
null
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun prepareVaultDir(path: String): Boolean {
|
||||
return try {
|
||||
val dir = File(path)
|
||||
dir.mkdirs()
|
||||
File(dir, ".helixnotes/trash").mkdirs()
|
||||
File(dir, ".helixnotes/attachments").mkdirs()
|
||||
dir.exists() && dir.canWrite()
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fun openFile(path: String) {
|
||||
try {
|
||||
val file = File(path)
|
||||
if (!file.exists()) return
|
||||
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
|
||||
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
|
||||
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("HelixNotes", "Failed to open file: $path", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class StorageBridge(private val activity: MainActivity) {
|
||||
@JavascriptInterface
|
||||
fun hasStoragePermission(): Boolean {
|
||||
return activity.hasStoragePermission()
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun requestStoragePermission() {
|
||||
activity.runOnUiThread {
|
||||
activity.requestStoragePermission()
|
||||
}
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun prepareVaultDir(path: String): Boolean {
|
||||
return activity.prepareVaultDir(path)
|
||||
}
|
||||
|
||||
@JavascriptInterface
|
||||
fun openFile(path: String) {
|
||||
activity.runOnUiThread {
|
||||
activity.openFile(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#5B6ABF"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Hello World!"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintLeft_toLeftOf="parent"
|
||||
app:layout_constraintRight_toRightOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 9.9 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.8 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 9.8 KiB |
@@ -0,0 +1,8 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme (dark mode). -->
|
||||
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">#1a1b26</item>
|
||||
<item name="android:navigationBarColor">#1a1b26</item>
|
||||
<item name="android:statusBarColor">#1a1b26</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,4 @@
|
||||
<resources>
|
||||
<string name="app_name">HelixNotes</string>
|
||||
<string name="main_activity_title">HelixNotes</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme (light mode). -->
|
||||
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
|
||||
<item name="android:windowBackground">#ffffff</item>
|
||||
<item name="android:navigationBarColor">#ffffff</item>
|
||||
<item name="android:statusBarColor">#ffffff</item>
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<external-path name="my_images" path="." />
|
||||
<cache-path name="my_cache_images" path="." />
|
||||
</paths>
|
||||
@@ -0,0 +1,22 @@
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
dependencies {
|
||||
classpath("com.android.tools.build:gradle:8.11.0")
|
||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean").configure {
|
||||
delete("build")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
`kotlin-dsl`
|
||||
}
|
||||
|
||||
gradlePlugin {
|
||||
plugins {
|
||||
create("pluginsForCoolKids") {
|
||||
id = "rust"
|
||||
implementationClass = "RustPlugin"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(gradleApi())
|
||||
implementation("com.android.tools.build:gradle:8.11.0")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import java.io.File
|
||||
import org.apache.tools.ant.taskdefs.condition.Os
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.logging.LogLevel
|
||||
import org.gradle.api.tasks.Input
|
||||
import org.gradle.api.tasks.TaskAction
|
||||
|
||||
open class BuildTask : DefaultTask() {
|
||||
@Input
|
||||
var rootDirRel: String? = null
|
||||
@Input
|
||||
var target: String? = null
|
||||
@Input
|
||||
var release: Boolean? = null
|
||||
|
||||
@TaskAction
|
||||
fun assemble() {
|
||||
val executable = """pnpm""";
|
||||
try {
|
||||
runTauriCli(executable)
|
||||
} catch (e: Exception) {
|
||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||
// Try different Windows-specific extensions
|
||||
val fallbacks = listOf(
|
||||
"$executable.exe",
|
||||
"$executable.cmd",
|
||||
"$executable.bat",
|
||||
)
|
||||
|
||||
var lastException: Exception = e
|
||||
for (fallback in fallbacks) {
|
||||
try {
|
||||
runTauriCli(fallback)
|
||||
return
|
||||
} catch (fallbackException: Exception) {
|
||||
lastException = fallbackException
|
||||
}
|
||||
}
|
||||
throw lastException
|
||||
} else {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runTauriCli(executable: String) {
|
||||
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
|
||||
val target = target ?: throw GradleException("target cannot be null")
|
||||
val release = release ?: throw GradleException("release cannot be null")
|
||||
val args = listOf("tauri", "android", "android-studio-script");
|
||||
|
||||
project.exec {
|
||||
workingDir(File(project.projectDir, rootDirRel))
|
||||
executable(executable)
|
||||
args(args)
|
||||
if (project.logger.isEnabled(LogLevel.DEBUG)) {
|
||||
args("-vv")
|
||||
} else if (project.logger.isEnabled(LogLevel.INFO)) {
|
||||
args("-v")
|
||||
}
|
||||
if (release) {
|
||||
args("--release")
|
||||
}
|
||||
args(listOf("--target", target))
|
||||
}.assertNormalExitValue()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import com.android.build.api.dsl.ApplicationExtension
|
||||
import org.gradle.api.DefaultTask
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
import org.gradle.kotlin.dsl.configure
|
||||
import org.gradle.kotlin.dsl.get
|
||||
|
||||
const val TASK_GROUP = "rust"
|
||||
|
||||
open class Config {
|
||||
lateinit var rootDirRel: String
|
||||
}
|
||||
|
||||
open class RustPlugin : Plugin<Project> {
|
||||
private lateinit var config: Config
|
||||
|
||||
override fun apply(project: Project) = with(project) {
|
||||
config = extensions.create("rust", Config::class.java)
|
||||
|
||||
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
|
||||
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
|
||||
|
||||
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
|
||||
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
|
||||
|
||||
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
|
||||
|
||||
extensions.configure<ApplicationExtension> {
|
||||
@Suppress("UnstableApiUsage")
|
||||
flavorDimensions.add("abi")
|
||||
productFlavors {
|
||||
create("universal") {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters += abiList
|
||||
}
|
||||
}
|
||||
defaultArchList.forEachIndexed { index, arch ->
|
||||
create(arch) {
|
||||
dimension = "abi"
|
||||
ndk {
|
||||
abiFilters.add(defaultAbiList[index])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
afterEvaluate {
|
||||
for (profile in listOf("debug", "release")) {
|
||||
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
|
||||
val buildTask = tasks.maybeCreate(
|
||||
"rustBuildUniversal$profileCapitalized",
|
||||
DefaultTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for all targets"
|
||||
}
|
||||
|
||||
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
|
||||
|
||||
for (targetPair in targetsList.withIndex()) {
|
||||
val targetName = targetPair.value
|
||||
val targetArch = archList[targetPair.index]
|
||||
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
|
||||
val targetBuildTask = project.tasks.maybeCreate(
|
||||
"rustBuild$targetArchCapitalized$profileCapitalized",
|
||||
BuildTask::class.java
|
||||
).apply {
|
||||
group = TASK_GROUP
|
||||
description = "Build dynamic library in $profile mode for $targetArch"
|
||||
rootDirRel = config.rootDirRel
|
||||
target = targetName
|
||||
release = profile == "release"
|
||||
}
|
||||
|
||||
buildTask.dependsOn(targetBuildTask)
|
||||
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
|
||||
targetBuildTask
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Project-wide Gradle settings.
|
||||
# IDE (e.g. Android Studio) users:
|
||||
# Gradle settings configured through the IDE *will override*
|
||||
# any settings specified in this file.
|
||||
# For more details on how to configure your build environment visit
|
||||
# http://www.gradle.org/docs/current/userguide/build_environment.html
|
||||
# Specifies the JVM arguments used for the daemon process.
|
||||
# The setting is particularly useful for tweaking memory settings.
|
||||
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
|
||||
# When configured, Gradle will run in incubating parallel mode.
|
||||
# This option should only be used with decoupled projects. More details, visit
|
||||
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
|
||||
# org.gradle.parallel=true
|
||||
# AndroidX package structure to make it clearer which packages are bundled with the
|
||||
# Android operating system, and which are packaged with your app"s APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
android.nonFinalResIds=false
|
||||
@@ -0,0 +1,6 @@
|
||||
#Tue May 10 19:22:52 CST 2022
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# https://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
@@ -0,0 +1,89 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,3 @@
|
||||
include ':app'
|
||||
|
||||
apply from: 'tauri.settings.gradle'
|
||||
|
Before Width: | Height: | Size: 6.1 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 5.3 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 6.6 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 1023 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 4.1 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 735 B After Width: | Height: | Size: 667 B |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 2.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 104 KiB After Width: | Height: | Size: 84 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.6 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.4 KiB |
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 6.2 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 7.0 KiB |
@@ -21,6 +21,8 @@ pub fn ai_request(
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
||||
let result = match provider.as_str() {
|
||||
"openai" => {
|
||||
stream_openai(
|
||||
@@ -40,7 +42,7 @@ pub fn ai_request(
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
None,
|
||||
key_opt,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
@@ -48,6 +50,24 @@ pub fn ai_request(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"openai_compatible" => {
|
||||
let url = base_url.as_deref().unwrap_or("");
|
||||
if url.is_empty() {
|
||||
Err("No base URL configured for OpenAI Compatible provider".to_string())
|
||||
} else {
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
key_opt,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
&request_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
stream_anthropic(
|
||||
&app,
|
||||
@@ -74,6 +94,13 @@ pub fn ai_request(
|
||||
});
|
||||
}
|
||||
|
||||
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
|
||||
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
|
||||
fn normalize_openai_base(base: &str) -> String {
|
||||
let b = base.trim().trim_end_matches('/');
|
||||
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
|
||||
}
|
||||
|
||||
async fn stream_anthropic(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
@@ -190,7 +217,6 @@ async fn stream_anthropic(
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended — send done
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
@@ -354,12 +380,21 @@ pub async fn test_connection(
|
||||
model: &str,
|
||||
base_url: Option<&str>,
|
||||
) -> Result<String, String> {
|
||||
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
|
||||
match provider {
|
||||
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
|
||||
"ollama" => {
|
||||
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
|
||||
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
|
||||
test_openai(&url, None, model).await
|
||||
test_openai(&url, key_opt, model).await
|
||||
}
|
||||
"openai_compatible" => {
|
||||
let url = base_url.unwrap_or("");
|
||||
if url.is_empty() {
|
||||
return Err("No base URL configured for OpenAI Compatible provider".to_string());
|
||||
}
|
||||
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
|
||||
test_openai(&url, key_opt, model).await
|
||||
}
|
||||
_ => test_anthropic(api_key, model).await,
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
// Keep hidden dirs (.helixnotes) — we'll handle attachments separately
|
||||
// Keep hidden dirs (.helixnotes) - we'll handle attachments separately
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -11,18 +11,18 @@ use tauri::{AppHandle, Manager, State};
|
||||
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
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)?);
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let search_bg = search.clone();
|
||||
let vault = path.clone();
|
||||
std::thread::spawn(move || {
|
||||
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)?;
|
||||
}
|
||||
@@ -51,6 +51,17 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.vaults.retain(|v| v.path != path);
|
||||
if config.active_vault.as_deref() == Some(path.as_str()) {
|
||||
config.active_vault = None;
|
||||
}
|
||||
save_app_config(&config)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
@@ -103,6 +114,14 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
|
||||
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 ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -292,7 +311,7 @@ pub fn create_note(
|
||||
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, 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 entry = operations::create_daily_note(vault_path, date.as_deref())?;
|
||||
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
|
||||
|
||||
if let Ok(search_guard) = state.search_index.lock() {
|
||||
if let Some(ref search) = *search_guard {
|
||||
@@ -379,10 +398,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 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 mut entries = Vec::new();
|
||||
|
||||
for entry in walkdir::WalkDir::new(vault)
|
||||
.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())
|
||||
{
|
||||
let path = entry.path();
|
||||
@@ -390,9 +413,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
|
||||
continue;
|
||||
}
|
||||
let path_str = path.to_string_lossy();
|
||||
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") {
|
||||
continue;
|
||||
}
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") {
|
||||
continue;
|
||||
}
|
||||
@@ -430,21 +450,28 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
|
||||
let vault = std::path::Path::new(vault_path);
|
||||
|
||||
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing)
|
||||
// Pass 1: collect ALL notes as nodes (no title deduplication - every note must appear)
|
||||
let mut graph_nodes = Vec::new();
|
||||
let mut title_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
// title → list of node indices (one title can map to multiple notes in different folders)
|
||||
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
|
||||
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
|
||||
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
// absolute path → idx (for fast active-note lookup)
|
||||
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
|
||||
let mut seen_paths: HashSet<String> = HashSet::new();
|
||||
let mut contents: Vec<String> = Vec::new();
|
||||
|
||||
let hn_dir = operations::helixnotes_dir(vault_path);
|
||||
for entry in walkdir::WalkDir::new(vault)
|
||||
.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())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
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; }
|
||||
// Skip Syncthing conflict files
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
@@ -462,12 +489,19 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
|
||||
});
|
||||
|
||||
// Deduplicate by title — skip if we already have a node with this title
|
||||
let title_lower = title.to_lowercase();
|
||||
if title_to_idx.contains_key(&title_lower) { continue; }
|
||||
|
||||
let idx = graph_nodes.len();
|
||||
title_to_idx.insert(title_lower, idx);
|
||||
let title_lower = title.to_lowercase();
|
||||
title_to_idxs.entry(title_lower).or_default().push(idx);
|
||||
path_to_idx.insert(path_str.clone(), idx);
|
||||
|
||||
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
|
||||
if let Ok(rel) = path.strip_prefix(vault) {
|
||||
let rel_no_ext = rel.with_extension("");
|
||||
// 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);
|
||||
}
|
||||
|
||||
graph_nodes.push(crate::types::GraphNode {
|
||||
title,
|
||||
path: path_str,
|
||||
@@ -476,8 +510,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
}
|
||||
|
||||
// Pass 2: extract edges from wiki-links (inline scan, no regex)
|
||||
let mut edges = Vec::new();
|
||||
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
|
||||
// edge_map: (src, tgt) → index in edges vec, used to detect reverse links
|
||||
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
|
||||
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
|
||||
|
||||
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
|
||||
if src == tgt { return; }
|
||||
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
|
||||
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
|
||||
// Reverse direction already exists - mark it as bidirectional
|
||||
edges[rev_idx].bidirectional = true;
|
||||
} else {
|
||||
let idx = edges.len();
|
||||
edge_map.insert((src, tgt), idx);
|
||||
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
|
||||
}
|
||||
};
|
||||
|
||||
for (source_idx, body) in contents.iter().enumerate() {
|
||||
let bytes = body.as_bytes();
|
||||
@@ -498,11 +546,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
|
||||
let link = link.split('^').next().unwrap_or(link);
|
||||
let link = link.trim().to_lowercase();
|
||||
|
||||
if let Some(&target_idx) = title_to_idx.get(&link) {
|
||||
if target_idx != source_idx {
|
||||
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
|
||||
if edge_set.insert(key) {
|
||||
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx });
|
||||
// 1. Try vault-relative path match (e.g. "arkhost/note name")
|
||||
if let Some(&target_idx) = relpath_to_idx.get(&link) {
|
||||
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
||||
} else if let Some(targets) = title_to_idxs.get(&link) {
|
||||
// 2. Title match - connect to all notes with this title
|
||||
for &target_idx in targets {
|
||||
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
||||
}
|
||||
} else if link.contains('/') {
|
||||
// 3. Path-based ref: try last segment as title fallback
|
||||
if let Some(seg) = link.rsplit('/').next() {
|
||||
if let Some(targets) = title_to_idxs.get(seg) {
|
||||
for &target_idx in targets {
|
||||
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -542,6 +600,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
|
||||
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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Search ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -635,7 +941,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
|
||||
|
||||
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
|
||||
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
let mut clipboard =
|
||||
@@ -660,14 +966,14 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
|
||||
Err("Clipboard image reading not supported on Android".to_string())
|
||||
}
|
||||
|
||||
/// Copy an image file to the system clipboard.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
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))?;
|
||||
@@ -687,12 +993,38 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
|
||||
Err("Clipboard image copy not supported on Android".to_string())
|
||||
}
|
||||
|
||||
/// Copy PNG bytes directly to the system clipboard.
|
||||
#[cfg(desktop)]
|
||||
#[tauri::command]
|
||||
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
|
||||
let img = image::load_from_memory(&data)
|
||||
.map_err(|e| format!("Failed to decode image: {}", e))?;
|
||||
let rgba = img.to_rgba8();
|
||||
let (w, h) = rgba.dimensions();
|
||||
let img_data = arboard::ImageData {
|
||||
width: w as usize,
|
||||
height: h as usize,
|
||||
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
|
||||
};
|
||||
let mut clipboard = arboard::Clipboard::new()
|
||||
.map_err(|e| format!("Clipboard init failed: {}", e))?;
|
||||
clipboard.set_image(img_data)
|
||||
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(mobile)]
|
||||
#[tauri::command]
|
||||
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
|
||||
Err("Clipboard image copy not supported on Android".to_string())
|
||||
}
|
||||
|
||||
// ── Attachments ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -746,6 +1078,8 @@ pub fn set_general_settings(
|
||||
state: State<'_, AppState>,
|
||||
compact_notes: bool,
|
||||
time_format: String,
|
||||
week_start: String,
|
||||
daily_title_format: String,
|
||||
gpu_acceleration: bool,
|
||||
autostart: bool,
|
||||
pdf_preview: bool,
|
||||
@@ -757,10 +1091,16 @@ pub fn set_general_settings(
|
||||
show_tray_icon: bool,
|
||||
close_to_tray: bool,
|
||||
enable_wiki_links: bool,
|
||||
show_note_dates: bool,
|
||||
restore_last_session: bool,
|
||||
) -> Result<(), String> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
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.week_start = week_start;
|
||||
config.daily_title_format = daily_title_format;
|
||||
config.gpu_acceleration = gpu_acceleration;
|
||||
config.autostart = autostart;
|
||||
config.pdf_preview = pdf_preview;
|
||||
@@ -1318,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
|
||||
Ok(result.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
// ── Orphaned attachment cleanup ──
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
pub struct OrphanAttachment {
|
||||
pub name: String,
|
||||
pub size: u64,
|
||||
}
|
||||
|
||||
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
|
||||
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
|
||||
// and matches each filename against both the raw note text and a percent-decoded copy (so a
|
||||
// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is
|
||||
// KEPT: a leftover orphan is harmless, a wrong deletion is not.
|
||||
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
|
||||
let attachments_dir = operations::helixnotes_dir(vault).join("attachments");
|
||||
if !attachments_dir.is_dir() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut files: Vec<(String, u64)> = Vec::new();
|
||||
for entry in std::fs::read_dir(&attachments_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
let p = entry.path();
|
||||
if p.is_file() {
|
||||
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
|
||||
files.push((name, size));
|
||||
}
|
||||
}
|
||||
if files.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut haystack = String::new();
|
||||
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
|
||||
let p = entry.path();
|
||||
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(content) = std::fs::read_to_string(p) {
|
||||
haystack.push_str(&content);
|
||||
haystack.push('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
let decoded = percent_decode(&haystack);
|
||||
let orphans = files
|
||||
.into_iter()
|
||||
.filter(|(name, _)| !haystack.contains(name.as_str()) && !decoded.contains(name.as_str()))
|
||||
.collect();
|
||||
Ok(orphans)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn find_orphaned_attachments(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<OrphanAttachment>, String> {
|
||||
let vault = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.clone().ok_or("No active vault")?
|
||||
};
|
||||
let orphans = scan_orphaned_attachments(&vault)?;
|
||||
Ok(orphans
|
||||
.into_iter()
|
||||
.map(|(name, size)| OrphanAttachment { name, size })
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn trash_orphaned_attachments(
|
||||
state: State<'_, AppState>,
|
||||
names: Vec<String>,
|
||||
) -> Result<u32, String> {
|
||||
let vault = {
|
||||
let config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
config.active_vault.clone().ok_or("No active vault")?
|
||||
};
|
||||
// Re-scan now and only move files that are STILL orphaned (guards against a reference added
|
||||
// between scan and confirm), intersected with the caller's selection.
|
||||
let current: std::collections::HashSet<String> = scan_orphaned_attachments(&vault)?
|
||||
.into_iter()
|
||||
.map(|(n, _)| n)
|
||||
.collect();
|
||||
let attachments_dir = operations::helixnotes_dir(&vault).join("attachments");
|
||||
let trash_dir = operations::helixnotes_dir(&vault).join("trash");
|
||||
std::fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
|
||||
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
|
||||
let mut moved = 0u32;
|
||||
for (i, name) in names.iter().enumerate() {
|
||||
if !current.contains(name) {
|
||||
continue;
|
||||
}
|
||||
// Path-traversal guard: only act on a bare filename inside the attachments dir.
|
||||
if std::path::Path::new(name)
|
||||
.file_name()
|
||||
.map(|f| f.to_string_lossy().to_string())
|
||||
.as_deref()
|
||||
!= Some(name.as_str())
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let src = attachments_dir.join(name);
|
||||
if !src.is_file() {
|
||||
continue;
|
||||
}
|
||||
let dest = trash_dir.join(format!("{}_{}_attachment_{}", stamp, i, name));
|
||||
if std::fs::rename(&src, &dest).is_ok() {
|
||||
moved += 1;
|
||||
}
|
||||
}
|
||||
Ok(moved)
|
||||
}
|
||||
|
||||
fn percent_decode(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.bytes();
|
||||
@@ -1370,14 +1819,13 @@ fn resolve_wiki_ref(
|
||||
reference.to_string()
|
||||
}
|
||||
|
||||
// ── Open file with system default handler ──
|
||||
// ── Open file/URL with system default handler ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_file(path: String) -> Result<(), String> {
|
||||
fn xdg_open(arg: &str) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut cmd = std::process::Command::new("xdg-open");
|
||||
cmd.arg(&path);
|
||||
cmd.arg(arg);
|
||||
// Clear AppImage environment so child processes find host binaries
|
||||
// (e.g. gio-launch-desktop on GNOME)
|
||||
if std::env::var("APPIMAGE").is_ok() {
|
||||
@@ -1390,31 +1838,52 @@ pub fn open_file(path: String) -> Result<(), String> {
|
||||
}
|
||||
}
|
||||
cmd.spawn()
|
||||
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
|
||||
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.arg(arg)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
|
||||
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
// CREATE_NO_WINDOW: open the URL/path without flashing a console window.
|
||||
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
|
||||
std::process::Command::new("cmd")
|
||||
.args(["/C", "start", "", &path])
|
||||
.args(["/C", "start", "", arg])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn()
|
||||
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
|
||||
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_file(path: String) -> Result<(), String> {
|
||||
xdg_open(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_url(url: String) -> Result<(), String> {
|
||||
xdg_open(&url)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
|
||||
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
|
||||
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
// ── Backup ──
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1577,12 +2046,22 @@ pub fn set_ai_settings(
|
||||
model: String,
|
||||
writing_style: 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> {
|
||||
let mut config = state.config.lock().map_err(|e| e.to_string())?;
|
||||
let key = api_key.filter(|k| !k.is_empty());
|
||||
match provider.as_deref() {
|
||||
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_provider = provider;
|
||||
@@ -1601,17 +2080,18 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
.ai_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = if provider == "ollama" {
|
||||
// Ollama doesn't require an API key
|
||||
Some(String::new())
|
||||
} else if provider == "openai" {
|
||||
config.openai_api_key.clone()
|
||||
} else {
|
||||
config.ai_api_key.clone()
|
||||
let key = match provider.as_str() {
|
||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
||||
"openai" => config.openai_api_key.clone(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
.ok_or("No API key configured")?;
|
||||
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)
|
||||
};
|
||||
|
||||
@@ -1642,6 +2122,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
|
||||
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]
|
||||
pub fn ai_ask(
|
||||
app: AppHandle,
|
||||
@@ -1657,26 +2259,28 @@ pub fn ai_ask(
|
||||
.ai_provider
|
||||
.clone()
|
||||
.unwrap_or_else(|| "anthropic".to_string());
|
||||
let key = if provider == "ollama" {
|
||||
Some(String::new())
|
||||
} else if provider == "openai" {
|
||||
config.openai_api_key.clone()
|
||||
} else {
|
||||
config.ai_api_key.clone()
|
||||
let key = match provider.as_str() {
|
||||
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
|
||||
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
|
||||
"openai" => config.openai_api_key.clone(),
|
||||
_ => config.ai_api_key.clone(),
|
||||
}
|
||||
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
|
||||
let model = config.ai_model.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)
|
||||
};
|
||||
|
||||
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
|
||||
You help users improve, rewrite, summarize, and transform their text. \
|
||||
Return ONLY the resulting text — no explanations, no markdown code fences, no preamble. \
|
||||
Return ONLY the resulting text - no explanations, no markdown code fences, no preamble. \
|
||||
Preserve the original language of the text unless specifically asked to translate. \
|
||||
Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \
|
||||
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions — they represent images and embedded files.".to_string();
|
||||
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions - they represent images and embedded files.".to_string();
|
||||
|
||||
if let Some(ref style) = writing_style {
|
||||
system_prompt.push_str(&format!(
|
||||
@@ -1721,17 +2325,21 @@ pub fn ai_ask(
|
||||
|
||||
// ── 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) {
|
||||
let _ = ANDROID_CONFIG_DIR.set(path);
|
||||
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
|
||||
let _ = MOBILE_CONFIG_DIR.set(path);
|
||||
}
|
||||
|
||||
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")
|
||||
} else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() {
|
||||
android_dir.join("helixnotes")
|
||||
} else {
|
||||
return Err("Config directory not available yet".to_string());
|
||||
};
|
||||
@@ -1756,8 +2364,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
|
||||
|
||||
// ── Install Type Detection ──
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_mobile_platform() -> bool {
|
||||
// Compile-time platform: true only for the Android/iOS builds. Authoritative, unlike the
|
||||
// webview user-agent, which some desktop WebKitGTK builds report mobile-looking (issue #63).
|
||||
cfg!(mobile)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_install_type() -> String {
|
||||
// Build-time override for distro packagers (e.g. Solus): build with
|
||||
// HELIXNOTES_INSTALL_TYPE=solus to report that type and suppress the in-app updater.
|
||||
if let Some(forced) = option_env!("HELIXNOTES_INSTALL_TYPE") {
|
||||
if !forced.is_empty() {
|
||||
return forced.to_string();
|
||||
}
|
||||
}
|
||||
if cfg!(target_os = "macos") {
|
||||
"macos".to_string()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
|
||||
@@ -157,7 +157,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
||||
.collect();
|
||||
|
||||
// Sort by name (timestamps sort lexicographically) — newest last
|
||||
// Sort by name (timestamps sort lexicographically) - newest last
|
||||
files.sort();
|
||||
|
||||
if files.len() as u32 > max {
|
||||
|
||||
@@ -4,6 +4,7 @@ mod commands;
|
||||
mod history;
|
||||
mod search;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod types;
|
||||
mod vault;
|
||||
|
||||
@@ -11,7 +12,7 @@ use state::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
@@ -20,14 +21,20 @@ use tauri::{
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
|
||||
#[cfg(target_os = "linux")]
|
||||
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||
}
|
||||
|
||||
rustls::crypto::ring::default_provider()
|
||||
.install_default()
|
||||
.expect("Failed to install rustls crypto provider");
|
||||
|
||||
let config = commands::load_app_config();
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let show_tray = config.show_tray_icon;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let close_to_tray = config.close_to_tray && show_tray;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
@@ -44,28 +51,28 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
|
||||
// On Android, set config dir from Tauri's path resolver, then reload config
|
||||
#[cfg(target_os = "android")]
|
||||
// On mobile, set config dir from Tauri's path resolver, then reload config
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
if let Ok(config_dir) = app.path().config_dir() {
|
||||
commands::set_android_config_dir(config_dir);
|
||||
commands::set_mobile_config_dir(config_dir);
|
||||
} else if let Ok(data_dir) = app.path().data_dir() {
|
||||
commands::set_android_config_dir(data_dir);
|
||||
commands::set_mobile_config_dir(data_dir);
|
||||
}
|
||||
// Reload config now that the Android config dir is available
|
||||
// Reload config now that the mobile config dir is available
|
||||
let reloaded = commands::load_app_config();
|
||||
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
|
||||
*cfg = reloaded;
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
}
|
||||
|
||||
// Check CLI args for a .md file path on initial launch
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let file_arg = std::env::args().skip(1).find(|a| {
|
||||
let a = a.trim();
|
||||
@@ -91,12 +98,14 @@ pub fn run() {
|
||||
.manage(app_state)
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::open_vault,
|
||||
commands::remove_vault,
|
||||
commands::get_app_config,
|
||||
commands::set_theme,
|
||||
commands::set_accent_color,
|
||||
commands::set_font_size,
|
||||
commands::set_font_family,
|
||||
commands::set_line_height,
|
||||
commands::set_ui_scale,
|
||||
commands::get_notebooks,
|
||||
commands::count_root_notes,
|
||||
commands::create_notebook,
|
||||
@@ -114,6 +123,10 @@ pub fn run() {
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
commands::set_task_priority,
|
||||
commands::set_task_due,
|
||||
commands::search_notes,
|
||||
commands::reindex,
|
||||
commands::get_trash,
|
||||
@@ -135,9 +148,14 @@ pub fn run() {
|
||||
commands::remove_quick_access,
|
||||
commands::reorder_quick_access,
|
||||
commands::get_vault_stats,
|
||||
commands::find_orphaned_attachments,
|
||||
commands::trash_orphaned_attachments,
|
||||
commands::import_obsidian,
|
||||
commands::open_file,
|
||||
commands::open_url,
|
||||
commands::copy_file_to,
|
||||
commands::write_bytes_to,
|
||||
commands::copy_png_to_clipboard,
|
||||
commands::create_backup,
|
||||
commands::list_backups,
|
||||
commands::restore_backup,
|
||||
@@ -149,7 +167,11 @@ pub fn run() {
|
||||
commands::set_ai_settings,
|
||||
commands::test_ai_connection,
|
||||
commands::ai_ask,
|
||||
commands::set_sync_settings,
|
||||
commands::test_sync_connection,
|
||||
commands::sync_now,
|
||||
commands::get_install_type,
|
||||
commands::is_mobile_platform,
|
||||
commands::get_pending_open_file,
|
||||
])
|
||||
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
|
||||
@@ -226,7 +248,7 @@ pub fn run() {
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
// Always show/focus the main window
|
||||
@@ -287,7 +309,7 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
|
||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||
|
||||
@@ -5,15 +5,163 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tantivy::collector::TopDocs;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
use tantivy::directory::MmapDirectory;
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
use tantivy::directory::RamDirectory;
|
||||
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
|
||||
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
|
||||
use tantivy::schema::*;
|
||||
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
|
||||
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Bumped whenever the index schema or tokenizer changes, so the on-disk index is
|
||||
/// wiped and rebuilt once on the next vault open (the index is derived from the
|
||||
/// notes, so this never loses data).
|
||||
const INDEX_SCHEMA_VERSION: &str = "2-cjk-bigram";
|
||||
|
||||
/// Per-vault search index dir in local app-data, kept out of the (possibly synced) vault.
|
||||
#[cfg(desktop)]
|
||||
fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||
use sha2::{Digest, Sha256};
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(vault_path.as_bytes());
|
||||
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||
}
|
||||
|
||||
/// True for characters in the CJK / Japanese / Korean blocks, which are written
|
||||
/// without spaces between words. These get uni/bi-gram tokenized so substring
|
||||
/// search works; everything else keeps the default word-splitting behaviour.
|
||||
fn is_cjk(c: char) -> bool {
|
||||
matches!(c as u32,
|
||||
0x3400..=0x4DBF // CJK Unified Ideographs Extension A
|
||||
| 0x4E00..=0x9FFF // CJK Unified Ideographs (Han)
|
||||
| 0x3040..=0x309F // Hiragana
|
||||
| 0x30A0..=0x30FF // Katakana
|
||||
| 0xAC00..=0xD7AF // Hangul Syllables
|
||||
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
|
||||
| 0xFF00..=0xFFEF // Halfwidth and Fullwidth Forms
|
||||
)
|
||||
}
|
||||
|
||||
/// Tokenize `text` so that CJK runs become overlapping unigrams + bigrams (recall
|
||||
/// over precision: a query for any substring of a CJK run will match), while runs
|
||||
/// of other alphanumeric characters become a single word token (same as tantivy's
|
||||
/// SimpleTokenizer, so Latin/English indexing is unchanged). Lowercasing is applied
|
||||
/// by a LowerCaser filter in the analyzer, not here.
|
||||
fn cjk_tokens(text: &str) -> Vec<Token> {
|
||||
let mut tokens: Vec<Token> = Vec::new();
|
||||
let mut position: usize = 0;
|
||||
let mut cjk_run: Vec<(usize, char)> = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut word_start: usize = 0;
|
||||
|
||||
macro_rules! flush_word {
|
||||
() => {{
|
||||
if !word.is_empty() {
|
||||
let len = word.len();
|
||||
tokens.push(Token {
|
||||
offset_from: word_start,
|
||||
offset_to: word_start + len,
|
||||
position,
|
||||
text: std::mem::take(&mut word),
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
}};
|
||||
}
|
||||
macro_rules! flush_cjk {
|
||||
() => {{
|
||||
let n = cjk_run.len();
|
||||
for i in 0..n {
|
||||
let (off, ch) = cjk_run[i];
|
||||
tokens.push(Token {
|
||||
offset_from: off,
|
||||
offset_to: off + ch.len_utf8(),
|
||||
position,
|
||||
text: ch.to_string(),
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
for i in 0..n.saturating_sub(1) {
|
||||
let (off1, c1) = cjk_run[i];
|
||||
let (off2, c2) = cjk_run[i + 1];
|
||||
let mut s = String::with_capacity(c1.len_utf8() + c2.len_utf8());
|
||||
s.push(c1);
|
||||
s.push(c2);
|
||||
tokens.push(Token {
|
||||
offset_from: off1,
|
||||
offset_to: off2 + c2.len_utf8(),
|
||||
position,
|
||||
text: s,
|
||||
position_length: 1,
|
||||
});
|
||||
position += 1;
|
||||
}
|
||||
cjk_run.clear();
|
||||
}};
|
||||
}
|
||||
|
||||
for (offset, c) in text.char_indices() {
|
||||
if is_cjk(c) {
|
||||
flush_word!();
|
||||
cjk_run.push((offset, c));
|
||||
} else if c.is_alphanumeric() {
|
||||
flush_cjk!();
|
||||
if word.is_empty() {
|
||||
word_start = offset;
|
||||
}
|
||||
word.push(c);
|
||||
} else {
|
||||
flush_cjk!();
|
||||
flush_word!();
|
||||
}
|
||||
}
|
||||
flush_cjk!();
|
||||
flush_word!();
|
||||
let _ = position; // the final flush increments position but nothing reads it after
|
||||
tokens
|
||||
}
|
||||
|
||||
/// A pre-computed token stream (all tokens collected up front by `cjk_tokens`).
|
||||
struct PreTokenizedStream {
|
||||
tokens: Vec<Token>,
|
||||
idx: usize,
|
||||
}
|
||||
|
||||
impl TokenStream for PreTokenizedStream {
|
||||
fn advance(&mut self) -> bool {
|
||||
if self.idx < self.tokens.len() {
|
||||
self.idx += 1;
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
fn token(&self) -> &Token {
|
||||
&self.tokens[self.idx - 1]
|
||||
}
|
||||
fn token_mut(&mut self) -> &mut Token {
|
||||
&mut self.tokens[self.idx - 1]
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CjkTokenizer;
|
||||
|
||||
impl Tokenizer for CjkTokenizer {
|
||||
type TokenStream<'a> = PreTokenizedStream;
|
||||
fn token_stream<'a>(&'a mut self, text: &'a str) -> PreTokenizedStream {
|
||||
PreTokenizedStream {
|
||||
tokens: cjk_tokens(text),
|
||||
idx: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SearchIndex {
|
||||
index: Index,
|
||||
writer: Mutex<Option<IndexWriter>>,
|
||||
@@ -29,29 +177,70 @@ impl SearchIndex {
|
||||
pub fn new(vault_path: &str) -> Result<Self, String> {
|
||||
let mut schema_builder = Schema::builder();
|
||||
let path_field = schema_builder.add_text_field("path", STRING | STORED);
|
||||
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
||||
let body_field = schema_builder.add_text_field("body", TEXT);
|
||||
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
|
||||
// CJK-aware tokenizer for the indexed text fields (keeps Latin/English
|
||||
// behaviour identical; adds substring matching for Chinese/Japanese/Korean).
|
||||
let cjk_indexing = TextFieldIndexing::default()
|
||||
.set_tokenizer("cjk")
|
||||
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
|
||||
let cjk_text = TextOptions::default().set_indexing_options(cjk_indexing.clone());
|
||||
let cjk_text_stored = cjk_text.clone().set_stored();
|
||||
let title_field = schema_builder.add_text_field("title", cjk_text_stored.clone());
|
||||
let body_field = schema_builder.add_text_field("body", cjk_text);
|
||||
let tags_field = schema_builder.add_text_field("tags", cjk_text_stored);
|
||||
let schema = schema_builder.build();
|
||||
|
||||
// Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem)
|
||||
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
|
||||
// Desktop: use mmap directory for persistent index on disk
|
||||
#[cfg(target_os = "android")]
|
||||
#[cfg(mobile)]
|
||||
let index = {
|
||||
let dir = RamDirectory::create();
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let index = {
|
||||
let index_dir = helixnotes_dir(vault_path).join("search_index");
|
||||
let base = vault_index_base(vault_path)
|
||||
.unwrap_or_else(|| helixnotes_dir(vault_path).join("search_index"));
|
||||
let index_dir = base.join("index");
|
||||
let version_path = base.join("version");
|
||||
|
||||
// Remove the old in-vault index so it stops syncing.
|
||||
let hn = helixnotes_dir(vault_path);
|
||||
let _ = fs::remove_dir_all(hn.join("search_index"));
|
||||
let _ = fs::remove_file(hn.join("search_index.version"));
|
||||
|
||||
// One-time wipe when the schema/tokenizer version changes; rebuild() repopulates.
|
||||
let version_ok = fs::read_to_string(&version_path)
|
||||
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
|
||||
.unwrap_or(false);
|
||||
if !version_ok {
|
||||
let _ = fs::remove_dir_all(&index_dir);
|
||||
}
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
let idx = match Index::open_or_create(dir, schema.clone()) {
|
||||
Ok(idx) => idx,
|
||||
Err(_) => {
|
||||
// Schema mismatch (older index) or corruption: wipe and recreate.
|
||||
let _ = fs::remove_dir_all(&index_dir);
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
let _ = fs::write(&version_path, INDEX_SCHEMA_VERSION);
|
||||
idx
|
||||
};
|
||||
|
||||
#[cfg(target_os = "android")]
|
||||
// Register the CJK-aware tokenizer (in-memory; must be done on every open,
|
||||
// before the writer is created, so both indexing and querying use it).
|
||||
index.tokenizers().register(
|
||||
"cjk",
|
||||
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
|
||||
);
|
||||
|
||||
#[cfg(mobile)]
|
||||
let heap_size = 15_000_000;
|
||||
#[cfg(not(target_os = "android"))]
|
||||
#[cfg(desktop)]
|
||||
let heap_size = 50_000_000;
|
||||
|
||||
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
|
||||
@@ -153,19 +342,40 @@ impl SearchIndex {
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
let terms: Vec<String> = query_str
|
||||
.split_whitespace()
|
||||
.map(|t| t.to_lowercase())
|
||||
.collect();
|
||||
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
|
||||
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
|
||||
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
|
||||
let mut analyzer = self
|
||||
.index
|
||||
.tokenizers()
|
||||
.get("cjk")
|
||||
.ok_or("cjk tokenizer not registered")?;
|
||||
let terms: Vec<String> = {
|
||||
let mut out = Vec::new();
|
||||
let mut stream = analyzer.token_stream(query_str);
|
||||
while stream.advance() {
|
||||
out.push(stream.token().text.clone());
|
||||
}
|
||||
out
|
||||
};
|
||||
|
||||
// For each search term, create prefix + fuzzy queries across all fields (OR),
|
||||
// then AND all terms together
|
||||
// For each term, OR queries across all fields, then AND all terms together.
|
||||
// CJK tokens (uni/bigrams) already encode segmentation -> exact term match.
|
||||
// Latin tokens keep prefix + fuzzy matching (unchanged English behaviour).
|
||||
let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
|
||||
.iter()
|
||||
.map(|term| {
|
||||
let is_cjk_term = term.chars().any(is_cjk);
|
||||
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
|
||||
.iter()
|
||||
.flat_map(|&field| {
|
||||
if is_cjk_term {
|
||||
let exact: Box<dyn Query> = Box::new(TermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
IndexRecordOption::WithFreqs,
|
||||
));
|
||||
vec![(Occur::Should, exact)]
|
||||
} else {
|
||||
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
|
||||
vec![Term::from_field_text(field, term)],
|
||||
));
|
||||
@@ -175,6 +385,7 @@ impl SearchIndex {
|
||||
true,
|
||||
));
|
||||
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
|
||||
|
||||
@@ -10,6 +10,7 @@ pub struct AppState {
|
||||
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||
pub importing: AtomicBool,
|
||||
pub syncing: AtomicBool,
|
||||
pub pending_open_file: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
@@ -20,6 +21,7 @@ impl AppState {
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,710 @@
|
||||
// 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).
|
||||
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||
/// wipes the local copy. (issue #102)
|
||||
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
reqwest::Url::parse(href)
|
||||
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||
.path()
|
||||
.to_string()
|
||||
} else {
|
||||
href.to_string()
|
||||
};
|
||||
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||
.into_owned();
|
||||
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||
.into_owned();
|
||||
let rest = match path.strip_prefix(&base) {
|
||||
Some(r) => r.trim_start_matches('/'),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||
Aborting before deleting anything locally - please report this URL."
|
||||
))
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return Ok(None); // the base collection itself
|
||||
}
|
||||
Ok(Some(rest.to_string()))
|
||||
}
|
||||
|
||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Cap {
|
||||
None,
|
||||
Href,
|
||||
Etag,
|
||||
}
|
||||
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut entries = Vec::new();
|
||||
let mut href: Option<String> = None;
|
||||
let mut etag: Option<String> = None;
|
||||
let mut is_dir = false;
|
||||
let mut cap = Cap::None;
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(e)) => match e.local_name().as_ref() {
|
||||
b"response" => {
|
||||
href = None;
|
||||
etag = None;
|
||||
is_dir = false;
|
||||
}
|
||||
b"href" => {
|
||||
cap = Cap::Href;
|
||||
buf.clear();
|
||||
}
|
||||
b"getetag" => {
|
||||
cap = Cap::Etag;
|
||||
buf.clear();
|
||||
}
|
||||
b"collection" => is_dir = true,
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Empty(e)) => {
|
||||
if e.local_name().as_ref() == b"collection" {
|
||||
is_dir = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => match e.local_name().as_ref() {
|
||||
b"href" => {
|
||||
href = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"getetag" => {
|
||||
etag = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"response" => {
|
||||
if let Some(h) = href.take() {
|
||||
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||
entries.push(RemoteEntry {
|
||||
relpath: rel,
|
||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("XML parse error: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// ───────────────────────── sync engine ─────────────────────────
|
||||
|
||||
fn apply_changes(
|
||||
client: &WebdavClient,
|
||||
vault: &str,
|
||||
local: &HashMap<String, LocalFile>,
|
||||
remote: &HashMap<String, String>,
|
||||
manifest: &SyncManifest,
|
||||
) -> Result<(SyncManifest, SyncSummary), String> {
|
||||
let vault_path = Path::new(vault);
|
||||
let mut new_m = SyncManifest::default();
|
||||
let mut sum = SyncSummary::default();
|
||||
let mut created: HashSet<String> = HashSet::new();
|
||||
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
|
||||
|
||||
let mut keys: HashSet<&String> = HashSet::new();
|
||||
keys.extend(local.keys());
|
||||
keys.extend(remote.keys());
|
||||
keys.extend(manifest.files.keys());
|
||||
|
||||
for key in keys {
|
||||
let l = local.get(key);
|
||||
let r = remote.get(key);
|
||||
let m = manifest.files.get(key);
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
} else if local_changed && !remote_changed {
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.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);
|
||||
|
||||
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||
if remote.is_empty() && !manifest.files.is_empty() {
|
||||
return Err(
|
||||
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||
even though files were synced before, so no local files were deleted. This usually \
|
||||
means the server reports paths in a format the app can't read yet - please report \
|
||||
your WebDAV provider and folder URL."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||
state.importing.store(true, Ordering::Relaxed);
|
||||
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
|
||||
state.importing.store(false, Ordering::Relaxed);
|
||||
|
||||
let (new_manifest, summary) = applied?;
|
||||
save_manifest(&vault, &new_manifest);
|
||||
|
||||
// Re-index after files changed (search is non-critical; ignore failures).
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.rebuild(&vault);
|
||||
}
|
||||
}
|
||||
|
||||
// The watcher was muted during apply, so nudge the UI to refresh its lists.
|
||||
let _ = app.emit(
|
||||
"file-changed",
|
||||
crate::types::FileEvent {
|
||||
event_type: "modify".to_string(),
|
||||
path: vault.clone(),
|
||||
},
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||