Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d | ||
|
|
93122ade7b | ||
|
|
80bdabaf1b | ||
|
|
cea98cffde | ||
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef | ||
|
|
000d68a0e7 | ||
|
|
d8c391a480 | ||
|
|
459af56564 | ||
|
|
9d82f2aed7 | ||
|
|
4fb99c7e39 | ||
|
|
92e8422cb0 | ||
|
|
5defddb35c | ||
|
|
dc19976568 | ||
|
|
bd59f6dbee | ||
|
|
26fa22afaa | ||
|
|
e2702bdd51 | ||
|
|
0d0ecee06e | ||
|
|
6115ad4ab6 | ||
|
|
a400eeefed | ||
|
|
f19ddee17d | ||
|
|
393505e1ac | ||
|
|
b87e8b8a38 | ||
|
|
5b62302b3a | ||
|
|
ee0ffdf121 | ||
|
|
89eea8e71b | ||
|
|
f27512c7ee | ||
|
|
eafd2b0a24 | ||
|
|
aeafd55491 | ||
|
|
ccc5e3b7c6 | ||
|
|
a28e514ed4 | ||
|
|
7c94370e48 | ||
|
|
ab39fc2ed6 | ||
|
|
621e2b31e0 | ||
|
|
ca3900815b | ||
|
|
b36ce9ed5b | ||
|
|
59677577ec | ||
|
|
71a0b9c0a0 | ||
|
|
3876b7db96 | ||
|
|
0436136533 | ||
|
|
139ecc8176 | ||
|
|
4b82d51f6c | ||
|
|
8ceb44df78 | ||
|
|
3b3e7fbbe6 | ||
|
|
7cbd112585 | ||
|
|
d275b0e783 | ||
|
|
fedbdfd2a1 | ||
|
|
7f2aae39d1 | ||
|
|
90f6f315f9 | ||
|
|
9c092e90f1 | ||
|
|
ed2fc87e86 | ||
|
|
7624c42e02 | ||
|
|
d46012b650 | ||
|
|
5d11ae6ea0 | ||
|
|
bc372993c4 | ||
|
|
4897703111 | ||
|
|
1bc067d470 | ||
|
|
565688b284 | ||
|
|
e87a2edc00 | ||
|
|
9f595212dd | ||
|
|
79f16b454a | ||
|
|
7be1b56b7c | ||
|
|
0b17a7c458 | ||
|
|
6c2bc72fbb | ||
|
|
e67ec21319 | ||
|
|
e87c8cbe1d | ||
|
|
4fac6d05a7 | ||
|
|
fffea45c50 | ||
|
|
eb88955e20 | ||
|
|
5180bd5a38 | ||
|
|
a6b9037ae0 | ||
|
|
88251a503c | ||
|
|
283340a0f4 | ||
|
|
545d4aec3b | ||
|
|
9315151a64 | ||
|
|
4d93696028 | ||
|
|
6891a9cdc5 | ||
|
|
1e7fb248b2 | ||
|
|
19fd44b65d | ||
|
|
deeefaa849 | ||
|
|
29bd00a265 | ||
|
|
37a837a33a | ||
|
|
a26f047d2b | ||
|
|
245935bab0 | ||
|
|
61fa9cf1d8 | ||
|
|
1440bdcd90 | ||
|
|
25d4cf7a9d | ||
|
|
0b5db1340c | ||
|
|
d098066899 | ||
|
|
9e2f347a4b | ||
|
|
3453d406fc | ||
|
|
73a53dcb85 | ||
|
|
abac0985bc | ||
|
|
af394c0b43 | ||
|
|
8f4f6f5f0e | ||
|
|
74f1c7f6b0 | ||
|
|
3b5cdf22eb | ||
|
|
31f4867e91 | ||
|
|
8baa976a02 | ||
|
|
b4824973a5 | ||
|
|
a3229d451e | ||
|
|
c1faf0b0f5 | ||
|
|
564c28904f | ||
|
|
06d53cea66 | ||
|
|
d9fe358e6b | ||
|
|
253449acc2 |
@@ -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,45 +1,147 @@
|
||||
# HelixNotes
|
||||
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
[](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
[](https://helixnotes.com)
|
||||
[]()
|
||||
|
||||
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.
|
||||
Your notes are stored as standard Markdown files on your local filesystem.
|
||||
No cloud, no lock-in.
|
||||
|
||||
## Download
|
||||
## Download (v1.3.2)
|
||||
|
||||
| Platform | Download | Notes |
|
||||
|----------|----------|-------|
|
||||
| Linux (Arch/rolling) | [HelixNotes_1.1.0_amd64.AppImage](https://download.helixnotes.com/releases/v1.1.0/HelixNotes_1.1.0_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
||||
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.1.0_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_amd64.deb) | Ubuntu 22.04+ |
|
||||
| Windows | [HelixNotes_1.1.0_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64-setup.exe) | Windows 10/11 |
|
||||
| macOS | [HelixNotes_1.1.0_x64.dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.1.0/HelixNotes_1.1.0_x64.dmg) | macOS 12+ (Intel, runs on Apple Silicon via Rosetta) |
|
||||
### Linux
|
||||
|
||||
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
#### Arch / Manjaro (AUR)
|
||||
|
||||
```bash
|
||||
yay -S helixnotes-appimage-bin
|
||||
```
|
||||
|
||||
#### Debian / Ubuntu / Mint (APT)
|
||||
|
||||
```bash
|
||||
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.3.2/HelixNotes_1.3.2_amd64.AppImage)
|
||||
|
||||
#### .deb (manual)
|
||||
|
||||
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+)
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11)
|
||||
|
||||
### macOS
|
||||
|
||||
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
|
||||
|
||||
### Android
|
||||
|
||||
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk)
|
||||
|
||||
---
|
||||
|
||||
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
||||
|
||||
## Features
|
||||
|
||||
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode
|
||||
- **Wiki-links** — link notes with `[[Note Title]]` syntax
|
||||
- **Graph view** — visualize connections between your notes
|
||||
- **Full-text search** powered by Tantivy
|
||||
- **Math support** — KaTeX rendering for inline and block equations
|
||||
- **Daily notes** — one-click button to create or open today's note
|
||||
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI)
|
||||
- **Version history** — per-note snapshots with diff view
|
||||
- **Backups** — automatic zip-based vault backups
|
||||
- **PDF preview** — inline rendering of embedded PDFs
|
||||
- **Tag management** — organize notes with tags, bulk edit from context menu
|
||||
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
|
||||
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
|
||||
- **Themes** — light/dark mode with customizable accent colors and fonts
|
||||
- **Focus mode** — distraction-free writing
|
||||
- **View mode** — read-only toggle for distraction-free reading
|
||||
- **Local** — everything stays on your machine
|
||||
- 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), 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 (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)
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
||||
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
|
||||
- **Platforms**: Linux (AppImage), Windows, macOS
|
||||
- **Platforms**: Linux (AppImage), Windows, macOS, Android
|
||||
|
||||
## Building from Source
|
||||
|
||||
@@ -63,6 +165,14 @@ pnpm tauri dev
|
||||
pnpm tauri build
|
||||
```
|
||||
|
||||
## Screenshots
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
## License
|
||||
|
||||
[AGPL-3.0](LICENSE)
|
||||
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1777268161,
|
||||
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
{
|
||||
description = "HelixNotes (local Markdown notes app) with Nix flake";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = {
|
||||
self,
|
||||
nixpkgs,
|
||||
flake-utils,
|
||||
}:
|
||||
flake-utils.lib.eachDefaultSystem (system: let
|
||||
pkgs = import nixpkgs {inherit system;};
|
||||
|
||||
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
|
||||
pname = cargoToml.package.name;
|
||||
version = cargoToml.package.version;
|
||||
in {
|
||||
packages.default = pkgs.rustPlatform.buildRustPackage {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
|
||||
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
|
||||
|
||||
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||
inherit pname version;
|
||||
src = ./.;
|
||||
fetcherVersion = 3;
|
||||
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
|
||||
};
|
||||
|
||||
nativeBuildInputs = with pkgs; [
|
||||
cargo-tauri.hook
|
||||
|
||||
pnpmConfigHook
|
||||
pnpm
|
||||
nodejs
|
||||
|
||||
pkg-config
|
||||
jq
|
||||
moreutils
|
||||
wrapGAppsHook3
|
||||
];
|
||||
|
||||
buildInputs = with pkgs;
|
||||
lib.optionals stdenv.hostPlatform.isLinux [
|
||||
webkitgtk_4_1
|
||||
libayatana-appindicator
|
||||
];
|
||||
|
||||
cargoRoot = "src-tauri";
|
||||
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
|
||||
|
||||
# Deactivate the upstream update mechanism
|
||||
postPatch = ''
|
||||
jq '
|
||||
.bundle.createUpdaterArtifacts = false |
|
||||
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
|
||||
' \
|
||||
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
|
||||
'';
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "HelixNotes desktop application";
|
||||
homepage = "https://helixnotes.com/";
|
||||
license = licenses.agpl3Plus;
|
||||
platforms = platforms.linux ++ platforms.darwin;
|
||||
mainProgram = "helixnotes";
|
||||
};
|
||||
};
|
||||
|
||||
formatter = pkgs.alejandra;
|
||||
});
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "helixnotes",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0-or-later",
|
||||
"version": "1.1.1",
|
||||
"version": "1.3.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -59,12 +59,14 @@
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@types/katex": "^0.16.8",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"highlight.js": "^11.11.1",
|
||||
"katex": "^0.16.28",
|
||||
"lowlight": "^3.3.0",
|
||||
"markdown-it": "^14.1.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "helixnotes"
|
||||
version = "1.1.1"
|
||||
version = "1.3.2"
|
||||
description = "Local markdown note-taking app"
|
||||
authors = ["HelixNotes"]
|
||||
license = "AGPL-3.0-or-later"
|
||||
@@ -20,14 +20,14 @@ tauri-plugin-log = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-updater = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
serde_yaml = "0.9"
|
||||
log = "0.4"
|
||||
notify = "8"
|
||||
walkdir = "2"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
|
||||
sys-locale = "0.3"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
tantivy = "0.22"
|
||||
gray_matter = "0.2"
|
||||
@@ -35,7 +35,17 @@ tokio = { version = "1", features = ["full"] }
|
||||
dirs = "6"
|
||||
regex = "1"
|
||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
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(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||
tauri-plugin-updater = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
|
||||
png = "0.17"
|
||||
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
|
||||
|
||||
@@ -2,16 +2,21 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "HelixNotes default capabilities",
|
||||
"windows": ["main"],
|
||||
"windows": ["main", "note-*"],
|
||||
"permissions": [
|
||||
"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",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-set-decorations",
|
||||
"core:window:allow-is-fullscreen",
|
||||
"core:window:allow-set-fullscreen",
|
||||
"core:window:allow-set-title",
|
||||
"core:webview:allow-create-webview-window",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
@@ -44,7 +49,6 @@
|
||||
{ "url": "mailto:*" }
|
||||
]
|
||||
},
|
||||
"opener:allow-reveal-item-in-dir",
|
||||
"updater:default"
|
||||
"opener:allow-reveal-item-in-dir"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "desktop",
|
||||
"description": "Desktop-only capabilities",
|
||||
"platforms": ["linux", "macOS", "windows"],
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"updater: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: 4.0 KiB After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 6.1 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 6.4 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 793 B After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
|
||||
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
|
||||
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
|
||||
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
|
||||
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 633 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 667 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 3.1 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 7.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
@@ -6,6 +6,7 @@ use crate::types::AiStreamEvent;
|
||||
|
||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||
|
||||
pub fn ai_request(
|
||||
app: AppHandle,
|
||||
@@ -15,15 +16,19 @@ pub fn ai_request(
|
||||
system_prompt: String,
|
||||
user_message: String,
|
||||
request_id: String,
|
||||
base_url: Option<String>,
|
||||
) {
|
||||
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(
|
||||
&app,
|
||||
&api_key,
|
||||
OPENAI_API_URL,
|
||||
Some(&api_key),
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
@@ -31,6 +36,38 @@ pub fn ai_request(
|
||||
)
|
||||
.await
|
||||
}
|
||||
"ollama" => {
|
||||
let url = base_url.as_deref().unwrap_or(OLLAMA_DEFAULT_URL);
|
||||
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
|
||||
stream_openai(
|
||||
&app,
|
||||
&url,
|
||||
key_opt,
|
||||
&model,
|
||||
&system_prompt,
|
||||
&user_message,
|
||||
&request_id,
|
||||
)
|
||||
.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,
|
||||
@@ -57,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,
|
||||
@@ -173,7 +217,6 @@ async fn stream_anthropic(
|
||||
}
|
||||
}
|
||||
|
||||
// Stream ended — send done
|
||||
let _ = app.emit(
|
||||
"ai-stream",
|
||||
AiStreamEvent {
|
||||
@@ -188,7 +231,8 @@ async fn stream_anthropic(
|
||||
|
||||
async fn stream_openai(
|
||||
app: &AppHandle,
|
||||
api_key: &str,
|
||||
url: &str,
|
||||
api_key: Option<&str>,
|
||||
model: &str,
|
||||
system_prompt: &str,
|
||||
user_message: &str,
|
||||
@@ -224,10 +268,15 @@ async fn stream_openai(
|
||||
body["temperature"] = json!(0.7);
|
||||
}
|
||||
|
||||
let response = client
|
||||
.post(OPENAI_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("content-type", "application/json")
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let response = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
@@ -325,9 +374,28 @@ async fn stream_openai(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn test_connection(provider: &str, api_key: &str, model: &str) -> Result<String, String> {
|
||||
pub async fn test_connection(
|
||||
provider: &str,
|
||||
api_key: &str,
|
||||
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(api_key, model).await,
|
||||
"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, 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,
|
||||
}
|
||||
}
|
||||
@@ -365,19 +433,12 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
||||
}
|
||||
}
|
||||
|
||||
async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
|
||||
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
|
||||
let client = Client::new();
|
||||
|
||||
let is_gpt5 = model.starts_with("gpt-5");
|
||||
let token_key = if is_gpt5 {
|
||||
"max_completion_tokens"
|
||||
} else {
|
||||
"max_tokens"
|
||||
};
|
||||
|
||||
let body = json!({
|
||||
"model": model,
|
||||
token_key: 10,
|
||||
"max_tokens": 10,
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
@@ -386,10 +447,15 @@ async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
|
||||
]
|
||||
});
|
||||
|
||||
let response = client
|
||||
.post(OPENAI_API_URL)
|
||||
.header("Authorization", format!("Bearer {}", api_key))
|
||||
.header("content-type", "application/json")
|
||||
let mut req = client
|
||||
.post(url)
|
||||
.header("content-type", "application/json");
|
||||
|
||||
if let Some(key) = api_key {
|
||||
req = req.header("Authorization", format!("Bearer {}", key));
|
||||
}
|
||||
|
||||
let response = req
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,36 +4,44 @@ mod commands;
|
||||
mod history;
|
||||
mod search;
|
||||
mod state;
|
||||
mod sync;
|
||||
mod types;
|
||||
mod vault;
|
||||
|
||||
use state::AppState;
|
||||
#[allow(unused_imports)]
|
||||
use tauri::{Emitter, Manager};
|
||||
|
||||
#[cfg(desktop)]
|
||||
use tauri::{
|
||||
image::Image,
|
||||
menu::{MenuBuilder, MenuItemBuilder},
|
||||
tray::TrayIconBuilder,
|
||||
Manager,
|
||||
};
|
||||
|
||||
#[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(desktop)]
|
||||
let show_tray = config.show_tray_icon;
|
||||
#[cfg(desktop)]
|
||||
let close_to_tray = config.close_to_tray && show_tray;
|
||||
let app_state = AppState::new(config);
|
||||
|
||||
let mut builder = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
}))
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
||||
.setup(move |app| {
|
||||
if cfg!(debug_assertions) {
|
||||
app.handle().plugin(
|
||||
@@ -43,21 +51,64 @@ pub fn run() {
|
||||
)?;
|
||||
}
|
||||
|
||||
// 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_mobile_config_dir(config_dir);
|
||||
} else if let Ok(data_dir) = app.path().data_dir() {
|
||||
commands::set_mobile_config_dir(data_dir);
|
||||
}
|
||||
// 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(desktop)]
|
||||
if show_tray {
|
||||
setup_tray(app)?;
|
||||
}
|
||||
|
||||
// Check CLI args for a .md file path on initial launch
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let file_arg = std::env::args().skip(1).find(|a| {
|
||||
let a = a.trim();
|
||||
!a.starts_with('-') && a.ends_with(".md")
|
||||
});
|
||||
if let Some(path) = file_arg {
|
||||
let resolved = if std::path::Path::new(&path).is_absolute() {
|
||||
std::path::PathBuf::from(&path)
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(&path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
let app_state = app.state::<AppState>();
|
||||
let _ = app_state.pending_open_file.lock().map(|mut p| {
|
||||
*p = Some(resolved_str.to_string());
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.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::set_content_width,
|
||||
commands::get_notebooks,
|
||||
commands::count_root_notes,
|
||||
commands::create_notebook,
|
||||
commands::rename_notebook,
|
||||
commands::delete_notebook,
|
||||
@@ -72,14 +123,22 @@ pub fn run() {
|
||||
commands::move_note,
|
||||
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,
|
||||
commands::restore_note,
|
||||
commands::restore_notebook,
|
||||
commands::permanent_delete,
|
||||
commands::empty_trash,
|
||||
commands::load_vault_state,
|
||||
commands::save_vault_state,
|
||||
commands::read_clipboard_image,
|
||||
commands::copy_image_to_clipboard,
|
||||
commands::save_image,
|
||||
commands::save_attachment,
|
||||
commands::get_notebook_icons,
|
||||
@@ -90,9 +149,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,
|
||||
@@ -104,14 +168,139 @@ 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| {
|
||||
let path = request.uri().path().to_string();
|
||||
std::thread::spawn(move || {
|
||||
let encoded = path.strip_prefix('/').unwrap_or(&path);
|
||||
let external_url = percent_decode(encoded);
|
||||
|
||||
if close_to_tray {
|
||||
builder = builder.on_window_event(|window, event| {
|
||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(400)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let client = match reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
{
|
||||
Ok(c) => c,
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
match client.get(&external_url).send() {
|
||||
Ok(resp) => {
|
||||
let content_type = resp
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.unwrap_or("application/octet-stream")
|
||||
.to_string();
|
||||
let status = resp.status().as_u16();
|
||||
match resp.bytes() {
|
||||
Ok(bytes) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(status)
|
||||
.header("Content-Type", &content_type)
|
||||
.header("Access-Control-Allow-Origin", "*")
|
||||
.body(bytes.to_vec())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = responder.respond(
|
||||
tauri::http::Response::builder()
|
||||
.status(502)
|
||||
.body(Vec::new())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
|
||||
// Always show/focus the main window
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
let _ = window.set_focus();
|
||||
}
|
||||
|
||||
// Extract .md file path from args (args[0] is the binary)
|
||||
let file_path = args.iter().skip(1).find(|arg| {
|
||||
let a = arg.trim();
|
||||
!a.starts_with('-') && a.ends_with(".md")
|
||||
});
|
||||
|
||||
if let Some(path) = file_path {
|
||||
let resolved = if std::path::Path::new(path.as_str()).is_absolute() {
|
||||
std::path::PathBuf::from(path)
|
||||
} else {
|
||||
std::path::Path::new(&cwd).join(path)
|
||||
};
|
||||
if let Some(resolved_str) = resolved.to_str() {
|
||||
let _ = app.emit("open-file", resolved_str.to_string());
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
|
||||
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
|
||||
|
||||
builder = builder.on_window_event(move |window, event| {
|
||||
match event {
|
||||
tauri::WindowEvent::CloseRequested { api, .. } => {
|
||||
// Only hide to tray for the main window
|
||||
if close_to_tray && window.label() == "main" {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
}
|
||||
}
|
||||
tauri::WindowEvent::Destroyed => {
|
||||
// When main window is destroyed, close all note windows
|
||||
if window.label() == "main" {
|
||||
let app = window.app_handle();
|
||||
for (label, win) in app.webview_windows() {
|
||||
if label.starts_with("note-") {
|
||||
let _ = win.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -121,6 +310,7 @@ pub fn run() {
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[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)?;
|
||||
@@ -159,3 +349,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn percent_decode(input: &str) -> String {
|
||||
let mut output = Vec::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%' && i + 2 < bytes.len() {
|
||||
if let Ok(decoded) =
|
||||
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
|
||||
{
|
||||
output.push(decoded);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
output.push(bytes[i]);
|
||||
i += 1;
|
||||
}
|
||||
String::from_utf8_lossy(&output).to_string()
|
||||
}
|
||||
|
||||
@@ -5,12 +5,163 @@ use std::fs;
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
use tantivy::collector::TopDocs;
|
||||
#[cfg(desktop)]
|
||||
use tantivy::directory::MmapDirectory;
|
||||
use tantivy::query::QueryParser;
|
||||
#[cfg(mobile)]
|
||||
use tantivy::directory::RamDirectory;
|
||||
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
|
||||
use tantivy::schema::*;
|
||||
use tantivy::{Index, IndexWriter, TantivyDocument};
|
||||
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>>,
|
||||
@@ -24,22 +175,75 @@ pub struct SearchIndex {
|
||||
|
||||
impl SearchIndex {
|
||||
pub fn new(vault_path: &str) -> Result<Self, String> {
|
||||
let index_dir = helixnotes_dir(vault_path).join("search_index");
|
||||
fs::create_dir_all(&index_dir).map_err(|e| e.to_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();
|
||||
|
||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
||||
let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?;
|
||||
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
|
||||
// Desktop: use mmap directory for persistent index on disk
|
||||
#[cfg(mobile)]
|
||||
let index = {
|
||||
let dir = RamDirectory::create();
|
||||
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
|
||||
};
|
||||
#[cfg(desktop)]
|
||||
let 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");
|
||||
|
||||
let writer = index
|
||||
.writer(50_000_000)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 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
|
||||
};
|
||||
|
||||
// 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(desktop)]
|
||||
let heap_size = 50_000_000;
|
||||
|
||||
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self {
|
||||
index,
|
||||
@@ -62,18 +266,20 @@ impl SearchIndex {
|
||||
|
||||
for entry in WalkDir::new(vault_path)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
.filter_entry(|e| {
|
||||
let p = e.path();
|
||||
p.is_file()
|
||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
||||
&& !p.starts_with(&hn_dir)
|
||||
!p.starts_with(&hn_dir)
|
||||
&& !p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_type().is_file()
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
{
|
||||
if let Ok(raw) = fs::read_to_string(entry.path()) {
|
||||
let filename = entry.file_name().to_string_lossy().to_string();
|
||||
@@ -135,12 +341,59 @@ impl SearchIndex {
|
||||
let reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||
let searcher = reader.searcher();
|
||||
|
||||
let query_parser =
|
||||
QueryParser::for_index(&self.index, vec![self.title_field, self.body_field, self.tags_field]);
|
||||
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||
// 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
|
||||
};
|
||||
|
||||
let query = query_parser
|
||||
.parse_query(query_str)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// 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)],
|
||||
));
|
||||
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
|
||||
Term::from_field_text(field, term),
|
||||
1,
|
||||
true,
|
||||
));
|
||||
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
|
||||
(Occur::Must, combined)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let query = BooleanQuery::new(term_queries);
|
||||
|
||||
let top_docs = searcher
|
||||
.search(&query, &TopDocs::with_limit(limit))
|
||||
|
||||
@@ -3,12 +3,15 @@ use crate::types::AppConfig;
|
||||
use notify::RecommendedWatcher;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
use std::sync::Mutex;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub struct AppState {
|
||||
pub config: Mutex<AppConfig>,
|
||||
pub search_index: Mutex<Option<SearchIndex>>,
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl AppState {
|
||||
@@ -18,6 +21,8 @@ impl AppState {
|
||||
search_index: Mutex::new(None),
|
||||
watcher: Mutex::new(None),
|
||||
importing: AtomicBool::new(false),
|
||||
syncing: AtomicBool::new(false),
|
||||
pending_open_file: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,725 @@
|
||||
// WebDAV sync provider.
|
||||
//
|
||||
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
|
||||
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
|
||||
// records what was in sync last time, so we can do a three-way diff (local vs remote
|
||||
// vs manifest) and resolve each file as upload/download/delete, with keep-both
|
||||
// conflict copies so nothing is ever lost.
|
||||
//
|
||||
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
|
||||
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
|
||||
// manifest itself) is local-only and never synced.
|
||||
|
||||
use crate::state::AppState;
|
||||
use crate::vault::operations::helixnotes_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::{Emitter, Manager};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
// ───────────────────────── config ─────────────────────────
|
||||
|
||||
pub struct WebdavConfig {
|
||||
pub url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Default, Clone, Serialize)]
|
||||
pub struct SyncSummary {
|
||||
pub uploaded: u32,
|
||||
pub downloaded: u32,
|
||||
pub deleted_local: u32,
|
||||
pub deleted_remote: u32,
|
||||
pub conflicts: u32,
|
||||
}
|
||||
|
||||
// ───────────────────────── manifest ─────────────────────────
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
struct SyncManifest {
|
||||
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
|
||||
}
|
||||
|
||||
#[derive(Clone, Serialize, Deserialize)]
|
||||
struct ManifestEntry {
|
||||
local_hash: String, // sha256 of local content at last sync
|
||||
remote_etag: String, // remote etag at last sync
|
||||
}
|
||||
|
||||
fn manifest_path(vault: &str) -> PathBuf {
|
||||
helixnotes_dir(vault).join("sync_state.json")
|
||||
}
|
||||
|
||||
fn load_manifest(vault: &str) -> SyncManifest {
|
||||
fs::read_to_string(manifest_path(vault))
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn save_manifest(vault: &str, m: &SyncManifest) {
|
||||
if let Ok(s) = serde_json::to_string(m) {
|
||||
let _ = fs::write(manifest_path(vault), s);
|
||||
}
|
||||
}
|
||||
|
||||
// ───────────────────────── helpers ─────────────────────────
|
||||
|
||||
fn sha256_hex(bytes: &[u8]) -> String {
|
||||
let mut h = Sha256::new();
|
||||
h.update(bytes);
|
||||
let digest = h.finalize();
|
||||
let mut s = String::with_capacity(64);
|
||||
for b in digest {
|
||||
s.push_str(&format!("{:02x}", b));
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
fn normalize_etag(s: &str) -> String {
|
||||
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
|
||||
}
|
||||
|
||||
/// Percent-encode each path segment, keeping the `/` separators.
|
||||
fn encode_path(relpath: &str) -> String {
|
||||
relpath
|
||||
.split('/')
|
||||
.map(|seg| urlencoding::encode(seg).into_owned())
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
|
||||
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
|
||||
let (dir, name) = match relpath.rfind('/') {
|
||||
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
|
||||
None => ("", relpath),
|
||||
};
|
||||
let (stem, ext) = match name.rfind('.') {
|
||||
Some(i) => (&name[..i], &name[i..]),
|
||||
None => (name, ""),
|
||||
};
|
||||
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
|
||||
}
|
||||
|
||||
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
|
||||
let target = vault.join(relpath);
|
||||
if let Some(parent) = target.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
fs::write(&target, bytes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
struct LocalFile {
|
||||
hash: String,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
|
||||
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
|
||||
/// folder with unrelated files never drags them into the vault.
|
||||
fn is_synced_relpath(rel: &str) -> bool {
|
||||
if rel.starts_with(".helixnotes/") {
|
||||
rel.starts_with(".helixnotes/attachments/")
|
||||
} else {
|
||||
rel.ends_with(".md")
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
|
||||
let vault_path = Path::new(vault);
|
||||
let mut map = HashMap::new();
|
||||
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
|
||||
let p = entry.path();
|
||||
if !p.is_file() {
|
||||
continue;
|
||||
}
|
||||
let rel = match p.strip_prefix(vault_path) {
|
||||
Ok(r) => r.to_string_lossy().replace('\\', "/"),
|
||||
Err(_) => continue,
|
||||
};
|
||||
if !is_synced_relpath(&rel) {
|
||||
continue;
|
||||
}
|
||||
let bytes = match fs::read(p) {
|
||||
Ok(b) => b,
|
||||
Err(_) => continue,
|
||||
};
|
||||
map.insert(
|
||||
rel,
|
||||
LocalFile {
|
||||
hash: sha256_hex(&bytes),
|
||||
path: p.to_path_buf(),
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
|
||||
// ───────────────────────── WebDAV client ─────────────────────────
|
||||
|
||||
struct RemoteEntry {
|
||||
relpath: String,
|
||||
etag: String,
|
||||
is_dir: bool,
|
||||
}
|
||||
|
||||
struct WebdavClient {
|
||||
base: String, // normalized, no trailing slash
|
||||
base_path: String, // path component of base, no trailing slash (percent-encoded)
|
||||
client: reqwest::blocking::Client,
|
||||
user: String,
|
||||
pass: String,
|
||||
}
|
||||
|
||||
impl WebdavClient {
|
||||
fn new(cfg: WebdavConfig) -> Result<Self, String> {
|
||||
let base = cfg.url.trim().trim_end_matches('/').to_string();
|
||||
if !base.starts_with("http://") && !base.starts_with("https://") {
|
||||
return Err("WebDAV URL must start with http:// or https://".to_string());
|
||||
}
|
||||
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
|
||||
let base_path = base_url.path().trim_end_matches('/').to_string();
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(60))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Self {
|
||||
base,
|
||||
base_path,
|
||||
client,
|
||||
user: cfg.username,
|
||||
pass: cfg.password,
|
||||
})
|
||||
}
|
||||
|
||||
fn url_for(&self, relpath: &str) -> String {
|
||||
if relpath.is_empty() {
|
||||
self.base.clone()
|
||||
} else {
|
||||
format!("{}/{}", self.base, encode_path(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
|
||||
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
|
||||
let resp = self
|
||||
.client
|
||||
.request(method, self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.header("Depth", depth)
|
||||
.header("Content-Type", "application/xml; charset=utf-8")
|
||||
.body(body)
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let status = resp.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err("Authentication failed (check username / app password)".to_string());
|
||||
}
|
||||
if status.as_u16() == 404 {
|
||||
return Err("WebDAV path not found (check the URL)".to_string());
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
|
||||
}
|
||||
let text = resp.text().map_err(|e| e.to_string())?;
|
||||
parse_multistatus(&text, &self.base_path)
|
||||
}
|
||||
|
||||
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
|
||||
/// (Depth: infinity is disabled on some servers, so we walk instead).
|
||||
fn list(&self) -> Result<HashMap<String, String>, String> {
|
||||
let mut files = HashMap::new();
|
||||
let mut visited: HashSet<String> = HashSet::new();
|
||||
let mut queue: Vec<String> = vec![String::new()];
|
||||
while let Some(dir) = queue.pop() {
|
||||
if !visited.insert(dir.clone()) {
|
||||
continue;
|
||||
}
|
||||
for e in self.propfind(&dir, "1")? {
|
||||
if e.relpath == dir {
|
||||
continue; // the collection itself
|
||||
}
|
||||
if e.is_dir {
|
||||
if !visited.contains(&e.relpath) {
|
||||
queue.push(e.relpath);
|
||||
}
|
||||
} else {
|
||||
files.insert(e.relpath, e.etag);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.get(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
}
|
||||
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
|
||||
}
|
||||
|
||||
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
|
||||
let resp = self
|
||||
.client
|
||||
.put(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.body(bytes.to_vec())
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
|
||||
}
|
||||
Ok(resp
|
||||
.headers()
|
||||
.get("etag")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(normalize_etag))
|
||||
}
|
||||
|
||||
fn delete(&self, relpath: &str) -> Result<(), String> {
|
||||
let resp = self
|
||||
.client
|
||||
.delete(self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status();
|
||||
if s.is_success() || s.as_u16() == 404 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
|
||||
}
|
||||
}
|
||||
|
||||
fn mkcol(&self, relpath: &str) -> Result<(), String> {
|
||||
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
|
||||
let resp = self
|
||||
.client
|
||||
.request(method, self.url_for(relpath))
|
||||
.basic_auth(&self.user, Some(&self.pass))
|
||||
.send()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let s = resp.status().as_u16();
|
||||
// 201 created; 405 already exists; 301 redirect-to-existing.
|
||||
if resp.status().is_success() || s == 405 || s == 301 {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
|
||||
}
|
||||
}
|
||||
|
||||
/// Ensure every ancestor collection of `relpath` exists on the remote.
|
||||
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
|
||||
let parts: Vec<&str> = relpath.split('/').collect();
|
||||
if parts.len() <= 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let mut cur = String::new();
|
||||
for part in &parts[..parts.len() - 1] {
|
||||
if !cur.is_empty() {
|
||||
cur.push('/');
|
||||
}
|
||||
cur.push_str(part);
|
||||
if created.insert(cur.clone()) {
|
||||
self.mkcol(&cur)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read a file's authoritative remote etag via a Depth-0 PROPFIND. The PUT-response
|
||||
/// etag is unreliable across WebDAV servers (some omit it, some differ in format from
|
||||
/// the getetag PROPFIND returns), so we always source the manifest etag from PROPFIND
|
||||
/// to keep it consistent with future listings. (issue #147)
|
||||
fn etag_for(&self, relpath: &str) -> String {
|
||||
self.propfind(relpath, "0")
|
||||
.ok()
|
||||
.and_then(|es| es.into_iter().find(|e| !e.is_dir).map(|e| e.etag))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// PUT a file (creating parent collections first), then read back its authoritative
|
||||
/// etag via PROPFIND. Returns the PROPFIND etag (empty if the server exposes no
|
||||
/// getetag), NOT the PUT-response etag, which is unreliable across servers. (issue #147)
|
||||
fn upload(
|
||||
&self,
|
||||
relpath: &str,
|
||||
bytes: &[u8],
|
||||
created: &mut HashSet<String>,
|
||||
) -> Result<String, String> {
|
||||
self.ensure_dirs(relpath, created)?;
|
||||
self.put(relpath, bytes)?; // PUT-response etag intentionally discarded
|
||||
Ok(self.etag_for(relpath))
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a WebDAV `href` to a vault-relative path (forward slash).
|
||||
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
|
||||
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
|
||||
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
|
||||
/// unmappable href makes that file look deleted on the remote, and the reconciler then
|
||||
/// wipes the local copy. (issue #102)
|
||||
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
|
||||
// Pull out just the path component (some servers return absolute-URL hrefs).
|
||||
let raw = if href.starts_with("http://") || href.starts_with("https://") {
|
||||
reqwest::Url::parse(href)
|
||||
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
|
||||
.path()
|
||||
.to_string()
|
||||
} else {
|
||||
href.to_string()
|
||||
};
|
||||
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
|
||||
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
|
||||
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
|
||||
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
|
||||
// both sides first makes the prefix match encoding-agnostic. (issue #102)
|
||||
let path = urlencoding::decode(raw.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
|
||||
.into_owned();
|
||||
let base = urlencoding::decode(base_path.trim_end_matches('/'))
|
||||
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
|
||||
.into_owned();
|
||||
let rest = match path.strip_prefix(&base) {
|
||||
Some(r) => r.trim_start_matches('/'),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
|
||||
Aborting before deleting anything locally - please report this URL."
|
||||
))
|
||||
}
|
||||
};
|
||||
if rest.is_empty() {
|
||||
return Ok(None); // the base collection itself
|
||||
}
|
||||
Ok(Some(rest.to_string()))
|
||||
}
|
||||
|
||||
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::reader::Reader;
|
||||
|
||||
#[derive(PartialEq)]
|
||||
enum Cap {
|
||||
None,
|
||||
Href,
|
||||
Etag,
|
||||
}
|
||||
|
||||
let mut reader = Reader::from_str(xml);
|
||||
let mut entries = Vec::new();
|
||||
let mut href: Option<String> = None;
|
||||
let mut etag: Option<String> = None;
|
||||
let mut is_dir = false;
|
||||
let mut cap = Cap::None;
|
||||
let mut buf = String::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event() {
|
||||
Ok(Event::Start(e)) => match e.local_name().as_ref() {
|
||||
b"response" => {
|
||||
href = None;
|
||||
etag = None;
|
||||
is_dir = false;
|
||||
}
|
||||
b"href" => {
|
||||
cap = Cap::Href;
|
||||
buf.clear();
|
||||
}
|
||||
b"getetag" => {
|
||||
cap = Cap::Etag;
|
||||
buf.clear();
|
||||
}
|
||||
b"collection" => is_dir = true,
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Empty(e)) => {
|
||||
if e.local_name().as_ref() == b"collection" {
|
||||
is_dir = true;
|
||||
}
|
||||
}
|
||||
Ok(Event::Text(e)) => {
|
||||
if cap != Cap::None {
|
||||
if let Ok(t) = e.unescape() {
|
||||
buf.push_str(&t);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Event::End(e)) => match e.local_name().as_ref() {
|
||||
b"href" => {
|
||||
href = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"getetag" => {
|
||||
etag = Some(buf.clone());
|
||||
cap = Cap::None;
|
||||
}
|
||||
b"response" => {
|
||||
if let Some(h) = href.take() {
|
||||
if let Some(rel) = href_to_relpath(&h, base_path)? {
|
||||
entries.push(RemoteEntry {
|
||||
relpath: rel,
|
||||
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
|
||||
is_dir,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Ok(Event::Eof) => break,
|
||||
Err(e) => return Err(format!("XML parse error: {e}")),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
// ───────────────────────── sync engine ─────────────────────────
|
||||
|
||||
fn apply_changes(
|
||||
client: &WebdavClient,
|
||||
vault: &str,
|
||||
local: &HashMap<String, LocalFile>,
|
||||
remote: &HashMap<String, String>,
|
||||
manifest: &SyncManifest,
|
||||
) -> Result<(SyncManifest, SyncSummary), String> {
|
||||
let vault_path = Path::new(vault);
|
||||
let mut new_m = SyncManifest::default();
|
||||
let mut sum = SyncSummary::default();
|
||||
let mut created: HashSet<String> = HashSet::new();
|
||||
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
|
||||
|
||||
let mut keys: HashSet<&String> = HashSet::new();
|
||||
keys.extend(local.keys());
|
||||
keys.extend(remote.keys());
|
||||
keys.extend(manifest.files.keys());
|
||||
|
||||
for key in keys {
|
||||
let l = local.get(key);
|
||||
let r = remote.get(key);
|
||||
let m = manifest.files.get(key);
|
||||
|
||||
match (l, r) {
|
||||
(Some(lf), Some(re)) => {
|
||||
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
|
||||
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
|
||||
if !local_changed && !remote_changed {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
} else if local_changed && !remote_changed {
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
} else if !local_changed && remote_changed {
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
} else {
|
||||
// both changed: compare contents; identical => no real conflict.
|
||||
let rbytes = client.get(key)?;
|
||||
let rhash = sha256_hex(&rbytes);
|
||||
if rhash == lf.hash {
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
// keep-both: remote becomes canonical; local saved as a conflict copy.
|
||||
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
write_local(vault_path, key, &rbytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: rhash,
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
let ckey = conflict_relpath(key, &stamp);
|
||||
write_local(vault_path, &ckey, &lbytes)?;
|
||||
let cetag = client.upload(&ckey, &lbytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
ckey,
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&lbytes),
|
||||
remote_etag: cetag,
|
||||
},
|
||||
);
|
||||
sum.conflicts += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
(Some(lf), None) => match m {
|
||||
None => {
|
||||
// new local -> upload
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
}
|
||||
Some(me) => {
|
||||
if me.local_hash != lf.hash {
|
||||
// remote deleted but local edited -> resurrect (upload)
|
||||
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
|
||||
let etag = client.upload(key, &bytes, &mut created)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: lf.hash.clone(),
|
||||
remote_etag: etag,
|
||||
},
|
||||
);
|
||||
sum.uploaded += 1;
|
||||
} else {
|
||||
// remote deleted, local unchanged -> delete local
|
||||
let _ = fs::remove_file(&lf.path);
|
||||
sum.deleted_local += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
(None, Some(re)) => match m {
|
||||
None => {
|
||||
// new remote -> download
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
}
|
||||
Some(me) => {
|
||||
if &me.remote_etag != re {
|
||||
// local deleted but remote changed -> resurrect (download)
|
||||
let bytes = client.get(key)?;
|
||||
write_local(vault_path, key, &bytes)?;
|
||||
new_m.files.insert(
|
||||
key.clone(),
|
||||
ManifestEntry {
|
||||
local_hash: sha256_hex(&bytes),
|
||||
remote_etag: re.clone(),
|
||||
},
|
||||
);
|
||||
sum.downloaded += 1;
|
||||
} else {
|
||||
// local deleted, remote unchanged -> delete remote
|
||||
client.delete(key)?;
|
||||
sum.deleted_remote += 1;
|
||||
}
|
||||
}
|
||||
},
|
||||
(None, None) => {
|
||||
// gone on both sides -> drop from manifest
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok((new_m, sum))
|
||||
}
|
||||
|
||||
/// Validate the connection (auth + URL) with a cheap PROPFIND.
|
||||
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
|
||||
let client = WebdavClient::new(cfg)?;
|
||||
client.propfind("", "0")?;
|
||||
Ok("Connection successful".to_string())
|
||||
}
|
||||
|
||||
/// Run a full sync. Mutes the file watcher while applying local writes, then
|
||||
/// rebuilds the search index. Returns a summary of what changed.
|
||||
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
let state = app.state::<AppState>();
|
||||
let client = WebdavClient::new(cfg)?;
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
|
||||
let remote: HashMap<String, String> = client
|
||||
.list()?
|
||||
.into_iter()
|
||||
.filter(|(k, _)| is_synced_relpath(k))
|
||||
.collect();
|
||||
let local = scan_local(&vault)?;
|
||||
let manifest = load_manifest(&vault);
|
||||
|
||||
// Safety guard (issue #102): never treat an empty remote listing as "everything was
|
||||
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
|
||||
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
|
||||
// map), not a real mass-deletion - so abort instead of wiping local notes.
|
||||
if remote.is_empty() && !manifest.files.is_empty() {
|
||||
return Err(
|
||||
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
|
||||
even though files were synced before, so no local files were deleted. This usually \
|
||||
means the server reports paths in a format the app can't read yet - please report \
|
||||
your WebDAV provider and folder URL."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
|
||||
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
|
||||
state.importing.store(true, Ordering::Relaxed);
|
||||
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
|
||||
state.importing.store(false, Ordering::Relaxed);
|
||||
|
||||
let (new_manifest, summary) = applied?;
|
||||
save_manifest(&vault, &new_manifest);
|
||||
|
||||
// Re-index after files changed (search is non-critical; ignore failures).
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.rebuild(&vault);
|
||||
}
|
||||
}
|
||||
|
||||
// The watcher was muted during apply, so nudge the UI to refresh its lists.
|
||||
let _ = app.emit(
|
||||
"file-changed",
|
||||
crate::types::FileEvent {
|
||||
event_type: "modify".to_string(),
|
||||
path: vault.clone(),
|
||||
},
|
||||
);
|
||||
Ok(summary)
|
||||
}
|
||||
@@ -19,6 +19,20 @@ pub struct NoteEntry {
|
||||
pub preview: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashContents {
|
||||
pub notes: Vec<NoteEntry>,
|
||||
pub notebooks: Vec<TrashNotebookEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrashNotebookEntry {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub note_count: usize,
|
||||
pub modified: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NotebookEntry {
|
||||
pub name: String,
|
||||
@@ -54,9 +68,21 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub font_family: Option<String>,
|
||||
#[serde(default)]
|
||||
pub line_height: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub ui_scale: Option<f64>,
|
||||
#[serde(default)]
|
||||
pub content_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub compact_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_note_dates: bool,
|
||||
#[serde(default)]
|
||||
pub time_format: String,
|
||||
#[serde(default = "default_week_start")]
|
||||
pub week_start: String,
|
||||
#[serde(default = "default_daily_title_format")]
|
||||
pub daily_title_format: String,
|
||||
#[serde(default)]
|
||||
pub gpu_acceleration: bool,
|
||||
#[serde(default)]
|
||||
@@ -70,6 +96,20 @@ pub struct AppConfig {
|
||||
#[serde(default)]
|
||||
pub hide_title_in_body: bool,
|
||||
#[serde(default)]
|
||||
pub show_line_numbers: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_link_arrows: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_all_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_quick_access: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_tasks: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_daily_notes: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub show_trash: bool,
|
||||
#[serde(default)]
|
||||
pub backup_enabled: bool,
|
||||
#[serde(default = "default_backup_frequency")]
|
||||
pub backup_frequency: String,
|
||||
@@ -89,6 +129,14 @@ pub struct AppConfig {
|
||||
pub ai_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ollama_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ollama_api_key: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_base_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub openai_compatible_api_key: Option<String>,
|
||||
#[serde(default = "default_ai_model")]
|
||||
pub ai_model: String,
|
||||
#[serde(default)]
|
||||
@@ -101,6 +149,25 @@ pub struct AppConfig {
|
||||
pub close_to_tray: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub enable_wiki_links: bool,
|
||||
#[serde(default)]
|
||||
pub restore_last_session: bool,
|
||||
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
|
||||
#[serde(default)]
|
||||
pub sync_provider: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdav_password: Option<String>,
|
||||
#[serde(default)]
|
||||
pub sync_on_open: bool,
|
||||
#[serde(default)]
|
||||
pub sync_on_change: bool,
|
||||
#[serde(default)]
|
||||
pub sync_interval_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub last_sync_time: Option<String>,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
@@ -123,12 +190,20 @@ fn default_title_mode() -> String {
|
||||
"input".to_string()
|
||||
}
|
||||
|
||||
fn default_week_start() -> String {
|
||||
"monday".to_string()
|
||||
}
|
||||
|
||||
fn default_daily_title_format() -> String {
|
||||
"localized".to_string()
|
||||
}
|
||||
|
||||
fn default_max_versions() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
fn default_ai_model() -> String {
|
||||
"claude-sonnet-4-5-20250929".to_string()
|
||||
"claude-sonnet-4-6".to_string()
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
@@ -140,14 +215,27 @@ impl Default for AppConfig {
|
||||
accent_color: None,
|
||||
font_size: None,
|
||||
font_family: None,
|
||||
line_height: None,
|
||||
ui_scale: None,
|
||||
content_width: None,
|
||||
compact_notes: false,
|
||||
show_note_dates: true,
|
||||
time_format: "relative".to_string(),
|
||||
week_start: "monday".to_string(),
|
||||
daily_title_format: "localized".to_string(),
|
||||
gpu_acceleration: true,
|
||||
autostart: false,
|
||||
pdf_preview: false,
|
||||
pdf_height: 600,
|
||||
title_mode: "input".to_string(),
|
||||
hide_title_in_body: false,
|
||||
show_line_numbers: false,
|
||||
show_link_arrows: true,
|
||||
show_all_notes: true,
|
||||
show_quick_access: true,
|
||||
show_tasks: true,
|
||||
show_daily_notes: true,
|
||||
show_trash: true,
|
||||
backup_enabled: false,
|
||||
backup_frequency: "24h".to_string(),
|
||||
backup_max_count: 10,
|
||||
@@ -158,12 +246,25 @@ impl Default for AppConfig {
|
||||
ai_provider: None,
|
||||
ai_api_key: None,
|
||||
openai_api_key: None,
|
||||
ai_model: "claude-sonnet-4-5-20250929".to_string(),
|
||||
ollama_base_url: None,
|
||||
ollama_api_key: None,
|
||||
openai_compatible_base_url: None,
|
||||
openai_compatible_api_key: None,
|
||||
ai_model: "claude-sonnet-4-6".to_string(),
|
||||
ai_writing_style: None,
|
||||
default_view_mode: false,
|
||||
show_tray_icon: false,
|
||||
close_to_tray: false,
|
||||
enable_wiki_links: true,
|
||||
restore_last_session: false,
|
||||
sync_provider: None,
|
||||
webdav_url: None,
|
||||
webdav_username: None,
|
||||
webdav_password: None,
|
||||
sync_on_open: false,
|
||||
sync_on_change: false,
|
||||
sync_interval_minutes: 0,
|
||||
last_sync_time: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,7 +276,31 @@ pub struct VaultState {
|
||||
pub notelist_width: f64,
|
||||
pub sidebar_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub notelist_collapsed: bool,
|
||||
#[serde(default)]
|
||||
pub collapsed_notebooks: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub notebook_sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub notebook_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub note_order: std::collections::HashMap<String, i32>,
|
||||
#[serde(default)]
|
||||
pub sort_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_view_mode: String,
|
||||
#[serde(default)]
|
||||
pub last_notebook: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_tag: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tasks_layout: String,
|
||||
#[serde(default = "default_true")]
|
||||
pub tasks_hide_completed: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_only_flagged: bool,
|
||||
#[serde(default)]
|
||||
pub tasks_sort: String,
|
||||
}
|
||||
|
||||
impl Default for VaultState {
|
||||
@@ -185,7 +310,19 @@ impl Default for VaultState {
|
||||
sidebar_width: 220.0,
|
||||
notelist_width: 280.0,
|
||||
sidebar_collapsed: false,
|
||||
notelist_collapsed: false,
|
||||
collapsed_notebooks: Vec::new(),
|
||||
notebook_sort_mode: String::new(),
|
||||
notebook_order: std::collections::HashMap::new(),
|
||||
note_order: std::collections::HashMap::new(),
|
||||
sort_mode: String::new(),
|
||||
last_view_mode: String::new(),
|
||||
last_notebook: None,
|
||||
last_tag: None,
|
||||
tasks_layout: String::new(),
|
||||
tasks_hide_completed: true,
|
||||
tasks_only_flagged: false,
|
||||
tasks_sort: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -245,3 +382,34 @@ pub struct NoteTitleEntry {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphData {
|
||||
pub nodes: Vec<GraphNode>,
|
||||
pub edges: Vec<GraphEdge>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphNode {
|
||||
pub title: String,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GraphEdge {
|
||||
pub source: usize,
|
||||
pub target: usize,
|
||||
pub bidirectional: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TaskItem {
|
||||
pub note_path: String,
|
||||
pub note_title: String,
|
||||
pub line: usize,
|
||||
pub raw_line: String,
|
||||
pub text: String,
|
||||
pub completed: bool,
|
||||
pub due: Option<String>,
|
||||
pub priority: Option<String>,
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
|
||||
.and_then(|s| parse_date_flexible(&s))
|
||||
.unwrap_or_else(Utc::now);
|
||||
|
||||
// Don't generate UUID on read — empty string signals "no ID yet"
|
||||
// Don't generate UUID on read - empty string signals "no ID yet"
|
||||
let id = fm.id.unwrap_or_default();
|
||||
|
||||
let meta = NoteMeta {
|
||||
@@ -77,6 +77,29 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
|
||||
(meta, content)
|
||||
}
|
||||
|
||||
/// Ensure the body starts with `# Title` heading for external app compatibility.
|
||||
/// If the body already starts with `# Title`, leave it as-is.
|
||||
fn ensure_title_heading(body: &str, title: &str) -> String {
|
||||
let trimmed = body.trim_start();
|
||||
|
||||
// Check if body already starts with `# Title`
|
||||
if let Some(rest) = trimmed.strip_prefix("# ") {
|
||||
let first_line_end = rest.find('\n').unwrap_or(rest.len());
|
||||
let heading_text = rest[..first_line_end].trim();
|
||||
if heading_text.eq_ignore_ascii_case(title.trim()) {
|
||||
return body.to_string();
|
||||
}
|
||||
}
|
||||
|
||||
// Don't add heading to empty notes (new/blank notes)
|
||||
if trimmed.is_empty() {
|
||||
return body.to_string();
|
||||
}
|
||||
|
||||
// Prepend `# Title` heading
|
||||
format!("# {}\n\n{}", title, body)
|
||||
}
|
||||
|
||||
pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
let tags_str = if meta.tags.is_empty() {
|
||||
"[]".to_string()
|
||||
@@ -104,7 +127,8 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
|
||||
|
||||
pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
|
||||
let fm = serialize_frontmatter(meta);
|
||||
format!("{}{}", fm, body)
|
||||
let body_with_heading = ensure_title_heading(body, &meta.title);
|
||||
format!("{}{}", fm, body_with_heading)
|
||||
}
|
||||
|
||||
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
|
||||
@@ -163,7 +187,8 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
|
||||
Err(_) => return update_note_raw(meta, body),
|
||||
};
|
||||
|
||||
format!("---\n{}---\n{}", yaml_str, body)
|
||||
let body_with_heading = ensure_title_heading(body, &meta.title);
|
||||
format!("---\n{}---\n{}", yaml_str, body_with_heading)
|
||||
}
|
||||
|
||||
fn filename_to_title(filename: &str) -> String {
|
||||
@@ -284,7 +309,7 @@ fn strip_html_and_markdown(input: &str) -> String {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Markdown links: [text](url) — keep the text, skip the URL
|
||||
// Markdown links: [text](url) - keep the text, skip the URL
|
||||
if ch == '[' {
|
||||
let mut link_text = String::new();
|
||||
let mut depth = 1;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
|
||||
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
|
||||
use crate::vault::frontmatter;
|
||||
use chrono::{Local, Utc};
|
||||
use chrono::{DateTime, Local, Locale, Utc};
|
||||
use rayon::prelude::*;
|
||||
use std::fs;
|
||||
use std::io::Read;
|
||||
@@ -59,11 +59,13 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
return entries;
|
||||
};
|
||||
|
||||
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
|
||||
let mut dirs: Vec<_> = read_dir
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let path = e.path();
|
||||
path.is_dir() && !is_hidden(&path)
|
||||
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
|
||||
&& !is_hidden(&e.path())
|
||||
&& e.file_name().to_string_lossy() != "Daily"
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -74,6 +76,8 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
});
|
||||
|
||||
// Store note_count for the parent directory (used when this is the top-level call)
|
||||
// Each directory pushes its own entry with its own note_count
|
||||
for dir_entry in dirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
@@ -83,77 +87,189 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let children = scan_dir_recursive(&path, vault_root);
|
||||
let note_count = count_notes_in_dir(&path);
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
|
||||
entries
|
||||
}
|
||||
|
||||
fn count_notes_in_dir(dir: &Path) -> usize {
|
||||
fs::read_dir(dir)
|
||||
.map(|rd| {
|
||||
rd.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
let path = e.path();
|
||||
path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
.count()
|
||||
})
|
||||
.unwrap_or(0)
|
||||
/// Combined scan: returns (children, note_count) in a single read_dir pass.
|
||||
fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usize) {
|
||||
let root = Path::new(vault_root);
|
||||
|
||||
let Ok(read_dir) = fs::read_dir(dir) else {
|
||||
return (Vec::new(), 0);
|
||||
};
|
||||
|
||||
let mut subdirs = Vec::new();
|
||||
let mut note_count = 0usize;
|
||||
for entry in read_dir.filter_map(|e| e.ok()) {
|
||||
let ft = match entry.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_dir() && !is_hidden(&entry.path()) {
|
||||
subdirs.push(entry);
|
||||
} else if ft.is_file() && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
note_count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
subdirs.sort_by(|a, b| {
|
||||
a.file_name()
|
||||
.to_string_lossy()
|
||||
.to_lowercase()
|
||||
.cmp(&b.file_name().to_string_lossy().to_lowercase())
|
||||
});
|
||||
|
||||
let mut entries = Vec::new();
|
||||
for dir_entry in subdirs {
|
||||
let path = dir_entry.path();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let relative = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
|
||||
|
||||
entries.push(NotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
children,
|
||||
note_count: child_note_count,
|
||||
});
|
||||
}
|
||||
|
||||
(entries, note_count)
|
||||
}
|
||||
|
||||
fn is_hidden(path: &Path) -> bool {
|
||||
pub(crate) fn is_hidden(path: &Path) -> bool {
|
||||
path.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|n| n.starts_with('.'))
|
||||
.map(|n| {
|
||||
n.starts_with('.')
|
||||
|| matches!(
|
||||
n,
|
||||
"_res" | "_resources" | "_attachments" | "_assets" | "assets" | "node_modules"
|
||||
)
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
|
||||
let root = Path::new(vault_path);
|
||||
if !root.exists() {
|
||||
return Err("Vault path does not exist".to_string());
|
||||
}
|
||||
let count = fs::read_dir(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
.count();
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
|
||||
let scan_path = notebook_path.unwrap_or(vault_path);
|
||||
let root = Path::new(scan_path);
|
||||
let vault_root = Path::new(vault_path);
|
||||
|
||||
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
|
||||
|
||||
if !root.exists() {
|
||||
return Err("Path does not exist".to_string());
|
||||
}
|
||||
|
||||
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
// Only direct children for a specific notebook
|
||||
fs::read_dir(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
} else {
|
||||
// Recursive for "All Notes"
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
};
|
||||
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
|
||||
#[cfg(mobile)]
|
||||
{
|
||||
let mut notes = Vec::new();
|
||||
// Log what read_dir sees
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
|
||||
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
|
||||
for (i, entry) in items.iter().enumerate().take(5) {
|
||||
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
|
||||
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
|
||||
}
|
||||
} else {
|
||||
log::info!("scan_notes: read_dir FAILED for {:?}", root);
|
||||
}
|
||||
|
||||
// Process files in parallel with partial reads
|
||||
let mut notes: Vec<NoteEntry> = md_files
|
||||
.par_iter()
|
||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||
.collect();
|
||||
if notebook_path.is_some() {
|
||||
if let Ok(rd) = fs::read_dir(root) {
|
||||
for entry in rd.filter_map(|e| e.ok()) {
|
||||
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
|
||||
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
|
||||
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
for entry in WalkDir::new(root)
|
||||
.into_iter()
|
||||
// filter_entry skips descending into hidden dirs (unlike filter)
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| {
|
||||
e.file_type().is_file()
|
||||
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
|
||||
})
|
||||
{
|
||||
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
}
|
||||
}
|
||||
log::info!("scan_notes: mobile scan found {} notes", notes.len());
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
return Ok(notes);
|
||||
}
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
|
||||
fs::read_dir(root)
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
} else {
|
||||
WalkDir::new(root)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
|
||||
.filter_map(|e| e.ok())
|
||||
.map(|e| e.path().to_path_buf())
|
||||
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut notes: Vec<NoteEntry> = md_files
|
||||
.par_iter()
|
||||
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
|
||||
.collect();
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
}
|
||||
}
|
||||
|
||||
fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
@@ -161,6 +277,51 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
|
||||
read_note_entry_from_str(&raw, path, vault_root)
|
||||
}
|
||||
|
||||
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
|
||||
/// uses filesystem timestamps for dates. No preview text.
|
||||
#[cfg(mobile)]
|
||||
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
// Read first 2KB - enough for frontmatter with tags, title, pinned
|
||||
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut buf = vec![0u8; 2048];
|
||||
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
|
||||
buf.truncate(bytes_read);
|
||||
let raw = String::from_utf8_lossy(&buf);
|
||||
|
||||
let filename = path
|
||||
.file_name()
|
||||
.unwrap_or_default()
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
// Always use filesystem timestamps on Android (faster than parsing date strings)
|
||||
if let Ok(fs_meta) = fs::metadata(path) {
|
||||
if let Ok(m) = fs_meta.modified() {
|
||||
meta.modified = m.into();
|
||||
}
|
||||
if let Ok(c) = fs_meta.created() {
|
||||
meta.created = c.into();
|
||||
}
|
||||
}
|
||||
|
||||
let relative = path
|
||||
.strip_prefix(vault_root)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let preview = frontmatter::extract_preview(&content, 120);
|
||||
|
||||
Ok(NoteEntry {
|
||||
path: path.to_string_lossy().to_string(),
|
||||
relative_path: relative,
|
||||
meta,
|
||||
preview,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview).
|
||||
fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
|
||||
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
@@ -225,7 +386,26 @@ pub fn read_note(path: &str) -> Result<NoteContent, String> {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
let (meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
|
||||
// Fall back to filesystem times when the frontmatter has no created/modified, matching the
|
||||
// note-list scan (read_note_entry_from_str). Without this, a missing `created:` defaults to
|
||||
// Utc::now() here and then gets written to disk on the next save (e.g. a title change),
|
||||
// resetting the create date of imported/frontmatter-less notes. (issue #139)
|
||||
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
|
||||
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
|
||||
if let Ok(fs_meta) = fs::metadata(p) {
|
||||
if !has_modified {
|
||||
if let Ok(modified) = fs_meta.modified() {
|
||||
meta.modified = modified.into();
|
||||
}
|
||||
}
|
||||
if !has_created {
|
||||
if let Ok(created) = fs_meta.created() {
|
||||
meta.created = created.into();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(NoteContent {
|
||||
path: path.to_string(),
|
||||
@@ -308,10 +488,78 @@ pub fn create_note(
|
||||
})
|
||||
}
|
||||
|
||||
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
|
||||
let today = Local::now();
|
||||
let date_str = today.format("%Y-%m-%d").to_string();
|
||||
let title = today.format("%B %d, %Y").to_string();
|
||||
fn get_system_locale() -> Locale {
|
||||
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
|
||||
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
|
||||
match lang {
|
||||
"af" => Locale::af_ZA,
|
||||
"ar" => Locale::ar_SA,
|
||||
"be" => Locale::be_BY,
|
||||
"bg" => Locale::bg_BG,
|
||||
"ca" => Locale::ca_ES,
|
||||
"cs" => Locale::cs_CZ,
|
||||
"da" => Locale::da_DK,
|
||||
"de" => Locale::de_DE,
|
||||
"el" => Locale::el_GR,
|
||||
"es" => Locale::es_ES,
|
||||
"et" => Locale::et_EE,
|
||||
"fi" => Locale::fi_FI,
|
||||
"fr" => Locale::fr_FR,
|
||||
"he" => Locale::he_IL,
|
||||
"hi" => Locale::hi_IN,
|
||||
"hr" => Locale::hr_HR,
|
||||
"hu" => Locale::hu_HU,
|
||||
"id" => Locale::id_ID,
|
||||
"is" => Locale::is_IS,
|
||||
"it" => Locale::it_IT,
|
||||
"ja" => Locale::ja_JP,
|
||||
"ka" => Locale::ka_GE,
|
||||
"ko" => Locale::ko_KR,
|
||||
"lt" => Locale::lt_LT,
|
||||
"lv" => Locale::lv_LV,
|
||||
"mk" => Locale::mk_MK,
|
||||
"nb" | "no" => Locale::nb_NO,
|
||||
"nl" => Locale::nl_NL,
|
||||
"nn" => Locale::nn_NO,
|
||||
"pl" => Locale::pl_PL,
|
||||
"pt" => Locale::pt_BR,
|
||||
"ro" => Locale::ro_RO,
|
||||
"ru" => Locale::ru_RU,
|
||||
"sk" => Locale::sk_SK,
|
||||
"sl" => Locale::sl_SI,
|
||||
"sq" => Locale::sq_AL,
|
||||
"sr" => Locale::sr_RS,
|
||||
"sv" => Locale::sv_SE,
|
||||
"th" => Locale::th_TH,
|
||||
"tr" => Locale::tr_TR,
|
||||
"uk" => Locale::uk_UA,
|
||||
"vi" => Locale::vi_VN,
|
||||
"zh" => Locale::zh_CN,
|
||||
_ => Locale::en_US,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_daily_note(
|
||||
vault_path: &str,
|
||||
date: Option<&str>,
|
||||
format: &str,
|
||||
) -> Result<NoteEntry, String> {
|
||||
let target_date = match date {
|
||||
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
|
||||
.map_err(|e| format!("Invalid date: {}", e))?,
|
||||
None => Local::now().date_naive(),
|
||||
};
|
||||
let date_str = target_date.format("%Y-%m-%d").to_string();
|
||||
let title = match format {
|
||||
"iso" => target_date.format("%Y-%m-%d").to_string(),
|
||||
"long" => target_date.format("%B %-d, %Y").to_string(),
|
||||
"us" => target_date.format("%m/%d/%Y").to_string(),
|
||||
"eu" => target_date.format("%d/%m/%Y").to_string(),
|
||||
_ => {
|
||||
let locale = get_system_locale();
|
||||
target_date.format_localized("%B %d, %Y", locale).to_string()
|
||||
}
|
||||
};
|
||||
|
||||
let dir = Path::new(vault_path).join("Daily");
|
||||
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||
@@ -426,17 +674,26 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
|
||||
let trash_name = format!("{}_{}", timestamp, dirname);
|
||||
let dest = trash_dir.join(&trash_name);
|
||||
|
||||
// Save original relative path in a sidecar .meta file for restore
|
||||
let relative = src
|
||||
.strip_prefix(vault_path)
|
||||
.unwrap_or(src)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
|
||||
let _ = fs::write(&meta_path, &relative);
|
||||
|
||||
fs::rename(src, dest).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
|
||||
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
|
||||
let src = Path::new(path);
|
||||
if !src.exists() {
|
||||
return Err("Note does not exist".to_string());
|
||||
}
|
||||
|
||||
// Update frontmatter
|
||||
// Read old title before renaming
|
||||
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
|
||||
let filename = src
|
||||
.file_name()
|
||||
@@ -444,6 +701,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
|
||||
let old_title = meta.title.clone();
|
||||
let old_path_str = src.to_string_lossy().to_string();
|
||||
|
||||
// Update frontmatter
|
||||
meta.title = new_title.to_string();
|
||||
meta.modified = Utc::now();
|
||||
if meta.id.is_empty() {
|
||||
@@ -460,7 +721,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
|
||||
fs::rename(src, &new_path).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(new_path.to_string_lossy().to_string())
|
||||
let new_path_str = new_path.to_string_lossy().to_string();
|
||||
|
||||
// Update wikilinks in other notes that reference this note
|
||||
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
|
||||
|
||||
Ok(new_path_str)
|
||||
}
|
||||
|
||||
/// Walk all .md files in the vault and update wikilink references after a note rename.
|
||||
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
|
||||
fn update_wikilinks_after_rename(
|
||||
vault_path: &str,
|
||||
old_path: &str,
|
||||
new_path: &str,
|
||||
old_title: &str,
|
||||
new_title: &str,
|
||||
) {
|
||||
let vault = Path::new(vault_path);
|
||||
let hn_dir = helixnotes_dir(vault_path);
|
||||
|
||||
// Vault-relative path ref (without .md), normalized to forward slashes so it
|
||||
// matches [[folder/note]] wikilinks on Windows (OS paths use backslashes there).
|
||||
let rel_ref = |p: &str| -> String {
|
||||
let rel = Path::new(p)
|
||||
.strip_prefix(vault)
|
||||
.map(|r| r.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|_| p.to_string())
|
||||
.replace('\\', "/");
|
||||
rel.strip_suffix(".md").unwrap_or(&rel).to_string()
|
||||
};
|
||||
let old_rel_ref = rel_ref(old_path);
|
||||
let new_rel_ref = rel_ref(new_path);
|
||||
|
||||
// Check if another note with the same old_title exists in the vault.
|
||||
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
|
||||
// because we can't tell which [[Old Title]] ref points to the renamed note
|
||||
// vs. the other note with the same title.
|
||||
let title_is_unique = !WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
.any(|e| {
|
||||
let p = e.path();
|
||||
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
|
||||
return false;
|
||||
}
|
||||
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
|
||||
return false;
|
||||
}
|
||||
// Check if this note's filename (without .md) matches the old title
|
||||
p.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.map(|s| s.eq_ignore_ascii_case(old_title))
|
||||
.unwrap_or(false)
|
||||
});
|
||||
|
||||
for entry in WalkDir::new(vault)
|
||||
.into_iter()
|
||||
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
|
||||
.filter_map(|e| e.ok())
|
||||
{
|
||||
let path = entry.path();
|
||||
if !path.is_file() { continue; }
|
||||
let path_str = path.to_string_lossy();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
|
||||
// Skip the renamed note itself
|
||||
if *path_str == *new_path { continue; }
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let mut result = content.clone();
|
||||
|
||||
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
|
||||
// Update all wikilink forms that reference the renamed note:
|
||||
|
||||
// Title-based rules only apply when the old title is unique in the vault.
|
||||
// If another note shares the same title, these would be ambiguous.
|
||||
if old_title != new_title && title_is_unique {
|
||||
// 1. Short title ref: [[Old Title]] → [[New Title]]
|
||||
result = result.replace(
|
||||
&format!("[[{}]]", old_title),
|
||||
&format!("[[{}]]", new_title),
|
||||
);
|
||||
|
||||
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
|
||||
result = result.replace(
|
||||
&format!("[[{}|", old_title),
|
||||
&format!("[[{}|", new_title),
|
||||
);
|
||||
|
||||
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
|
||||
result = result.replace(
|
||||
&format!("|{}]]", old_title),
|
||||
&format!("|{}]]", new_title),
|
||||
);
|
||||
}
|
||||
|
||||
// Path-based rules are always safe (paths are unique).
|
||||
if old_rel_ref != new_rel_ref {
|
||||
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
|
||||
result = result.replace(
|
||||
&format!("[[{}]]", old_rel_ref),
|
||||
&format!("[[{}]]", new_rel_ref),
|
||||
);
|
||||
|
||||
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
|
||||
// Also update the alias if it matches the old title.
|
||||
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
|
||||
if old_title != new_title {
|
||||
result = result.replace(
|
||||
&format!("[[{}|{}]]", old_rel_ref, old_title),
|
||||
&format!("[[{}|{}]]", new_rel_ref, new_title),
|
||||
);
|
||||
}
|
||||
// For aliases that DON'T match the old title, just update the ref part
|
||||
result = result.replace(
|
||||
&format!("[[{}|", old_rel_ref),
|
||||
&format!("[[{}|", new_rel_ref),
|
||||
);
|
||||
}
|
||||
|
||||
if result != content {
|
||||
let _ = fs::write(path, &result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
|
||||
@@ -528,14 +916,29 @@ pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, S
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
/// Remove a directory inside trash if it's empty (after restoring/deleting its last note).
|
||||
fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
if let Some(d) = dir {
|
||||
if d != trash_dir && d.starts_with(&trash_dir) {
|
||||
if let Ok(mut entries) = fs::read_dir(d) {
|
||||
if entries.next().is_none() {
|
||||
let _ = fs::remove_dir(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
|
||||
let trash_dir = helixnotes_dir(vault_path).join("trash");
|
||||
if !trash_dir.exists() {
|
||||
return Ok(Vec::new());
|
||||
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
|
||||
}
|
||||
|
||||
let vault_root = Path::new(vault_path);
|
||||
let mut notes = Vec::new();
|
||||
let mut notebooks = Vec::new();
|
||||
|
||||
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
|
||||
let entry = entry.map_err(|e| e.to_string())?;
|
||||
@@ -544,11 +947,38 @@ pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
|
||||
if let Ok(note) = read_note_entry(&path, vault_root) {
|
||||
notes.push(note);
|
||||
}
|
||||
} else if path.is_dir() {
|
||||
let note_count = WalkDir::new(&path)
|
||||
.min_depth(1)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
|
||||
.count();
|
||||
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
|
||||
// Strip timestamp prefix to get original notebook name
|
||||
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
|
||||
dirname[18..].to_string()
|
||||
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
|
||||
dirname[15..].to_string()
|
||||
} else {
|
||||
dirname.to_string()
|
||||
};
|
||||
let modified = fs::metadata(&path)
|
||||
.and_then(|m| m.modified())
|
||||
.map(|t| DateTime::<Utc>::from(t))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
notebooks.push(TrashNotebookEntry {
|
||||
name,
|
||||
path: path.to_string_lossy().to_string(),
|
||||
note_count,
|
||||
modified,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
|
||||
Ok(notes)
|
||||
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
Ok(TrashContents { notes, notebooks })
|
||||
}
|
||||
|
||||
pub fn restore_note(
|
||||
@@ -576,18 +1006,66 @@ pub fn restore_note(
|
||||
&filename
|
||||
};
|
||||
|
||||
let parent = src.parent().map(|p| p.to_path_buf());
|
||||
let dest = dest_dir.join(original_name);
|
||||
fs::rename(src, &dest).map_err(|e| e.to_string())?;
|
||||
|
||||
// Clean up empty parent directory (deleted notebook folder) in trash
|
||||
cleanup_empty_trash_dir(vault_path, parent.as_deref());
|
||||
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn permanent_delete(path: &str) -> Result<(), String> {
|
||||
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
|
||||
let src = Path::new(trash_path);
|
||||
if !src.exists() || !src.is_dir() {
|
||||
return Err("Trashed notebook does not exist".to_string());
|
||||
}
|
||||
|
||||
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
|
||||
|
||||
// Try to read original path from sidecar .meta file
|
||||
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
|
||||
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
|
||||
original
|
||||
} else {
|
||||
// Fallback for notebooks trashed before .meta files existed:
|
||||
// strip timestamp prefix to get notebook name, restore to root
|
||||
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
|
||||
&dirname[18..]
|
||||
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
|
||||
&dirname[15..]
|
||||
} else {
|
||||
&dirname
|
||||
};
|
||||
name.to_string()
|
||||
};
|
||||
|
||||
let dest = Path::new(vault_path).join(&relative);
|
||||
|
||||
// Recreate parent directories if needed
|
||||
if let Some(parent) = dest.parent() {
|
||||
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
fs::rename(src, &dest).map_err(|e| e.to_string())?;
|
||||
// Clean up .meta file
|
||||
let _ = fs::remove_file(&meta_path);
|
||||
Ok(dest.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
|
||||
let p = Path::new(path);
|
||||
let parent = p.parent().map(|pp| pp.to_path_buf());
|
||||
if p.is_dir() {
|
||||
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
fs::remove_file(p).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
// Clean up empty parent directory (deleted notebook folder) in trash
|
||||
cleanup_empty_trash_dir(vault_path, parent.as_deref());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||