Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4bb3b89d6 | ||
|
|
9b51aef073 | ||
|
|
4aa0b200b9 | ||
|
|
8769b56fd1 | ||
|
|
06d2cd744f | ||
|
|
7428cc7dfb | ||
|
|
6c89d668fe | ||
|
|
2c76a4747e | ||
|
|
fd55c00015 | ||
|
|
28ef37f354 | ||
|
|
bf5a9f134d | ||
|
|
2991940ba8 | ||
|
|
54352a03d8 | ||
|
|
c4d1f12b2e | ||
|
|
aa699007f7 | ||
|
|
047a8b3c3d | ||
|
|
6098dd01b4 | ||
|
|
b16beb9af5 | ||
|
|
95565d7506 | ||
|
|
a13d17ed81 | ||
|
|
95515cac9d | ||
|
|
21dd2d1681 | ||
|
|
6d5f12c47c | ||
|
|
d01bd4ea01 | ||
|
|
bd7d66b1e8 | ||
|
|
0b9fe69819 | ||
|
|
936c38a21d | ||
|
|
e60e637437 | ||
|
|
479c957698 | ||
|
|
13fd70f944 | ||
|
|
015726f896 | ||
|
|
5e2c31598c | ||
|
|
24d1ba9f8f | ||
|
|
5345442de2 | ||
|
|
6ea2ca0ae2 | ||
|
|
6f4f526d07 | ||
|
|
13bad87500 | ||
|
|
acecc9c68d | ||
|
|
35a8501f2d | ||
|
|
09f9df968d | ||
|
|
2c58bf3d9a | ||
|
|
e063f1c73a | ||
|
|
cafc0ce1ec | ||
|
|
a192723fbd | ||
|
|
b643eeb884 | ||
|
|
eb042e5760 | ||
|
|
6251d96193 | ||
|
|
db92dabba5 | ||
|
|
2469a971ca | ||
|
|
a43bf05bf0 | ||
|
|
ffd9ebad7e | ||
|
|
d2100c8500 | ||
|
|
7e63d721bc | ||
|
|
2c774c51c6 | ||
|
|
8eba4527af | ||
|
|
181b3eb6c2 | ||
|
|
879d2f42c6 | ||
|
|
85ea26b57a | ||
|
|
800c81e598 | ||
|
|
30c3523722 | ||
|
|
761bf5b42e | ||
|
|
b10a7b1bfc | ||
|
|
313d86df23 | ||
|
|
95627cbb5c | ||
|
|
b9ea14f330 | ||
|
|
812d025a9d | ||
|
|
11cc2daa4b | ||
|
|
5c36f2aa71 | ||
|
|
987b1b9ed7 | ||
|
|
3ef0e14581 | ||
|
|
f98b3415bc | ||
|
|
16805c96aa | ||
|
|
9f2e0ba8b6 | ||
|
|
7b3609afbb | ||
|
|
84ff75f223 | ||
|
|
56dc0eefd7 | ||
|
|
eeea16531a | ||
|
|
ac5ab2a27a | ||
|
|
fef4ece2c0 | ||
|
|
613451f4d8 | ||
|
|
04dee78b27 | ||
|
|
69330bf23c | ||
|
|
4843bcacd5 | ||
|
|
3d28665e1e | ||
|
|
209585a0b4 | ||
|
|
84eab75ec8 | ||
|
|
ce17b38509 | ||
|
|
85c1e1e7d3 | ||
|
|
ebf38e2281 | ||
|
|
32ec62419d | ||
|
|
e46fda91a4 | ||
|
|
62d5388176 | ||
|
|
db2bef892a | ||
|
|
1c46935cc5 | ||
|
|
f0f19125d5 | ||
|
|
828f83d2e3 | ||
|
|
002fdb763e | ||
|
|
d3ff1fb931 | ||
|
|
7951472ae3 | ||
|
|
5cffe8846b | ||
|
|
806e975e31 | ||
|
|
e4232269f6 | ||
|
|
ab759a006d | ||
|
|
ca7f25003c | ||
|
|
08ab0b90f2 | ||
|
|
e275f5c0bb | ||
|
|
683a01627e | ||
|
|
3c11a7f63e | ||
|
|
90a96552d3 | ||
|
|
a710603b0a | ||
|
|
80a6fdca69 | ||
|
|
1dc78ca916 | ||
|
|
3118af1e41 | ||
|
|
a669d96aed | ||
|
|
a0b1fa0481 | ||
|
|
2e8c048a3e | ||
|
|
d2ddc7f0de | ||
|
|
3b88b20c61 | ||
|
|
f8371aaa57 | ||
|
|
c5a45131a5 | ||
|
|
1681fade3b | ||
|
|
4f324ad682 | ||
|
|
c1a9871703 | ||
|
|
83390f5ed2 | ||
|
|
5bf6d8063e | ||
|
|
a1913558b9 | ||
|
|
93e96829f4 | ||
|
|
11673bd8c4 | ||
|
|
4f4e2854a6 | ||
|
|
856ac94a76 | ||
|
|
28263de53d | ||
|
|
b6e855ce3e | ||
|
|
51148efe2e | ||
|
|
32e056dc24 | ||
|
|
c14ad6387d | ||
|
|
93122ade7b | ||
|
|
80bdabaf1b | ||
|
|
cea98cffde | ||
|
|
922bcee1e2 | ||
|
|
d33bb8d96e | ||
|
|
93ebb1987e | ||
|
|
cc0134dd22 | ||
|
|
1164a6bd86 | ||
|
|
6fcf7bdb5e | ||
|
|
8454f2eca8 | ||
|
|
7e480ad7e2 | ||
|
|
bc7c985489 | ||
|
|
a94c33edaf | ||
|
|
a1404062c5 | ||
|
|
9bb55afccc | ||
|
|
173e25dadc | ||
|
|
2fa9458eab | ||
|
|
0a3b7a13e3 | ||
|
|
50d27d5636 | ||
|
|
d050ac907f | ||
|
|
80ca411a08 | ||
|
|
35894cdfd1 | ||
|
|
4df96362f4 | ||
|
|
fc7d34612d | ||
|
|
1b7dc77792 | ||
|
|
5f311f2404 | ||
|
|
f19c3c2caa | ||
|
|
0f7b1debbb | ||
|
|
4aa399e96e | ||
|
|
15c6e6388a | ||
|
|
b501d2bd5c | ||
|
|
fb4c5748a8 | ||
|
|
d9cb615f1f | ||
|
|
0e09d1f9f1 | ||
|
|
db924f7352 | ||
|
|
2fea44bde6 | ||
|
|
784248f984 | ||
|
|
ee16ef07c5 | ||
|
|
3648a2dc90 | ||
|
|
8f250d1ab9 | ||
|
|
67d5ae87c3 | ||
|
|
08abbe25ef | ||
|
|
000d68a0e7 | ||
|
|
d8c391a480 | ||
|
|
459af56564 | ||
|
|
9d82f2aed7 | ||
|
|
4fb99c7e39 | ||
|
|
92e8422cb0 | ||
|
|
5defddb35c | ||
|
|
dc19976568 | ||
|
|
bd59f6dbee | ||
|
|
26fa22afaa | ||
|
|
e2702bdd51 | ||
|
|
0d0ecee06e | ||
|
|
6115ad4ab6 | ||
|
|
a400eeefed | ||
|
|
f19ddee17d | ||
|
|
393505e1ac | ||
|
|
b87e8b8a38 | ||
|
|
5b62302b3a | ||
|
|
ee0ffdf121 | ||
|
|
89eea8e71b | ||
|
|
f27512c7ee | ||
|
|
eafd2b0a24 | ||
|
|
aeafd55491 | ||
|
|
ccc5e3b7c6 | ||
|
|
a28e514ed4 | ||
|
|
7c94370e48 | ||
|
|
ab39fc2ed6 | ||
|
|
621e2b31e0 | ||
|
|
ca3900815b | ||
|
|
b36ce9ed5b | ||
|
|
59677577ec | ||
|
|
71a0b9c0a0 | ||
|
|
3876b7db96 | ||
|
|
0436136533 | ||
|
|
139ecc8176 | ||
|
|
4b82d51f6c | ||
|
|
8ceb44df78 | ||
|
|
3b3e7fbbe6 | ||
|
|
7cbd112585 | ||
|
|
d275b0e783 | ||
|
|
fedbdfd2a1 | ||
|
|
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 | ||
|
|
bfea8c998a | ||
|
|
81dadd4be1 | ||
|
|
b3939bcfb3 | ||
|
|
6e75468420 | ||
|
|
2697e10907 |
@@ -1,5 +1,10 @@
|
|||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
|
||||||
|
package-lock.json
|
||||||
|
yarn.lock
|
||||||
|
bun.lockb
|
||||||
|
|
||||||
# Output
|
# Output
|
||||||
.output
|
.output
|
||||||
.vercel
|
.vercel
|
||||||
@@ -8,9 +13,20 @@ node_modules
|
|||||||
/.svelte-kit
|
/.svelte-kit
|
||||||
/build
|
/build
|
||||||
|
|
||||||
|
# Nix
|
||||||
|
/result
|
||||||
|
|
||||||
# Tauri
|
# Tauri
|
||||||
/src-tauri/target
|
/src-tauri/target
|
||||||
/src-tauri/gen
|
|
||||||
|
# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested
|
||||||
|
# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out.
|
||||||
|
/src-tauri/gen/*
|
||||||
|
!/src-tauri/gen/android/
|
||||||
|
|
||||||
|
# Never commit signing secrets (nested ignore misses keystore.properties)
|
||||||
|
/src-tauri/gen/android/keystore.properties
|
||||||
|
/src-tauri/gen/android/key.properties
|
||||||
|
|
||||||
# OS
|
# OS
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|||||||
@@ -1,41 +1,155 @@
|
|||||||
# HelixNotes
|
# HelixNotes
|
||||||
|
|
||||||
A local-first markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
[](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
|
||||||
|
[](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||||
|
[](https://helixnotes.com)
|
||||||
|
[]()
|
||||||
|
|
||||||
Your notes are stored as standard Markdown files on your local filesystem. No cloud, no lock-in.
|
A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
|
||||||
|
|
||||||
## Download
|
Your notes are stored as standard Markdown files on your local filesystem.
|
||||||
|
No cloud, no lock-in.
|
||||||
|
|
||||||
| Platform | Download | Notes |
|
## Download (v1.3.3)
|
||||||
|----------|----------|-------|
|
|
||||||
| Linux (Arch/rolling) | [HelixNotes_1.0.9_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.AppImage) | Best for Arch, Fedora, openSUSE |
|
|
||||||
| Linux (Debian/Ubuntu/Mint) | [HelixNotes_1.0.9_amd64.deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_amd64.deb) | Ubuntu 22.04+ |
|
|
||||||
| Windows | [HelixNotes_1.0.9_x64-setup.exe](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.9/HelixNotes_1.0.9_x64-setup.exe) | Windows 10/11 |
|
|
||||||
| macOS | Coming soon | |
|
|
||||||
|
|
||||||
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
|
### Linux
|
||||||
|
|
||||||
|
#### 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://gitlab.com/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.3/HelixNotes_1.3.3_amd64.AppImage)
|
||||||
|
|
||||||
|
#### .deb (manual)
|
||||||
|
|
||||||
|
[Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
[Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
[Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs)
|
||||||
|
|
||||||
|
[Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
|
||||||
|
|
||||||
|
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
|
||||||
|
>
|
||||||
|
> ```bash
|
||||||
|
> xattr -cr /Applications/HelixNotes.app
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### Android
|
||||||
|
|
||||||
|
[Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode
|
- Markdown editor with toolbar, slash commands, source mode, code highlighting
|
||||||
- **Wiki-links** — link notes with `[[Note Title]]` syntax
|
- **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)
|
||||||
- **Graph view** — visualize connections between your notes
|
- `[[Wiki-links]]` and graph view
|
||||||
- **Full-text search** powered by Tantivy
|
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
|
||||||
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI)
|
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
|
||||||
- **Version history** — per-note snapshots with diff view
|
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
|
||||||
- **Backups** — automatic zip-based vault backups
|
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
|
||||||
- **PDF preview** — inline rendering of embedded PDFs
|
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
|
||||||
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
|
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
|
||||||
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
|
- Manual notebook sorting (drag to reorder above, into, or below)
|
||||||
- **Themes** — light/dark mode with customizable accent colors and fonts
|
- External `.md` viewer mode with import-to-vault flow
|
||||||
- **Focus mode** — distraction-free writing
|
- PDF preview, Obsidian import, "Show in File Manager"
|
||||||
- **Local-first** — everything stays on your machine
|
- 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
|
## Tech Stack
|
||||||
|
|
||||||
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
|
||||||
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
|
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
|
||||||
- **Platforms**: Linux (AppImage), Windows, macOS
|
- **Platforms**: Linux (AppImage), Windows, macOS, Android
|
||||||
|
|
||||||
## Building from Source
|
## Building from Source
|
||||||
|
|
||||||
@@ -59,6 +173,14 @@ pnpm tauri dev
|
|||||||
pnpm tauri build
|
pnpm tauri build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Screenshots
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
[AGPL-3.0](LICENSE)
|
[AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/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": 1782959384,
|
||||||
|
"narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
|
||||||
|
"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,82 @@
|
|||||||
|
{
|
||||||
|
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-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
|
||||||
|
|
||||||
|
pnpmDeps = pkgs.fetchPnpmDeps {
|
||||||
|
inherit pname version;
|
||||||
|
src = ./.;
|
||||||
|
fetcherVersion = 3;
|
||||||
|
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
|
||||||
|
};
|
||||||
|
|
||||||
|
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.nixfmt;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
"name": "helixnotes",
|
"name": "helixnotes",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0-or-later",
|
"license": "AGPL-3.0-or-later",
|
||||||
"version": "1.0.9",
|
"version": "1.3.3",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@@ -57,12 +57,16 @@
|
|||||||
"@tiptap/extension-underline": "^3.19.0",
|
"@tiptap/extension-underline": "^3.19.0",
|
||||||
"@tiptap/pm": "^3.19.0",
|
"@tiptap/pm": "^3.19.0",
|
||||||
"@tiptap/starter-kit": "^3.19.0",
|
"@tiptap/starter-kit": "^3.19.0",
|
||||||
|
"@types/katex": "^0.16.8",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
|
"highlight.js": "^11.11.1",
|
||||||
|
"katex": "^0.16.28",
|
||||||
"lowlight": "^3.3.0",
|
"lowlight": "^3.3.0",
|
||||||
"markdown-it": "^14.1.0",
|
"markdown-it": "^14.1.0",
|
||||||
"markdown-it-mark": "^4.0.0",
|
"markdown-it-mark": "^4.0.0",
|
||||||
"markdown-it-sub": "^2.0.0",
|
"markdown-it-sub": "^2.0.0",
|
||||||
"markdown-it-sup": "^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,7 +1,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "helixnotes"
|
name = "helixnotes"
|
||||||
version = "1.0.9"
|
version = "1.3.3"
|
||||||
description = "Local-first markdown note-taking app"
|
description = "Local markdown note-taking app"
|
||||||
authors = ["HelixNotes"]
|
authors = ["HelixNotes"]
|
||||||
license = "AGPL-3.0-or-later"
|
license = "AGPL-3.0-or-later"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -20,14 +20,14 @@ tauri-plugin-log = "2"
|
|||||||
tauri-plugin-dialog = "2"
|
tauri-plugin-dialog = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-updater = "2"
|
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde_yaml = "0.9"
|
serde_yaml = "0.9"
|
||||||
log = "0.4"
|
log = "0.4"
|
||||||
notify = "8"
|
notify = "8"
|
||||||
walkdir = "2"
|
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"] }
|
uuid = { version = "1", features = ["v4"] }
|
||||||
tantivy = "0.22"
|
tantivy = "0.22"
|
||||||
gray_matter = "0.2"
|
gray_matter = "0.2"
|
||||||
@@ -35,7 +35,17 @@ tokio = { version = "1", features = ["full"] }
|
|||||||
dirs = "6"
|
dirs = "6"
|
||||||
regex = "1"
|
regex = "1"
|
||||||
zip = { version = "2", default-features = false, features = ["deflate"] }
|
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"
|
futures = "0.3"
|
||||||
rayon = "1"
|
rayon = "1"
|
||||||
|
quick-xml = "0.36"
|
||||||
|
urlencoding = "2"
|
||||||
|
sha2 = "0.10"
|
||||||
|
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
|
||||||
|
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
|
||||||
|
tauri-plugin-updater = "2"
|
||||||
tauri-plugin-single-instance = "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",
|
"$schema": "../gen/schemas/desktop-schema.json",
|
||||||
"identifier": "default",
|
"identifier": "default",
|
||||||
"description": "HelixNotes default capabilities",
|
"description": "HelixNotes default capabilities",
|
||||||
"windows": ["main"],
|
"windows": ["main", "note-*"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
"core:default",
|
||||||
"core:window:default",
|
"core:window:default",
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
|
"core:window:allow-start-resize-dragging",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
"core:window:allow-is-maximized",
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-set-decorations",
|
"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:default",
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
@@ -44,7 +49,6 @@
|
|||||||
{ "url": "mailto:*" }
|
{ "url": "mailto:*" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"opener:allow-reveal-item-in-dir",
|
"opener:allow-reveal-item-in-dir"
|
||||||
"updater:default"
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
|
||||||
|
Required for notarization. These do NOT enable App Sandbox.
|
||||||
|
Without these, the WebKit webview and bundled Rust libraries get killed by the
|
||||||
|
hardened runtime's library validation. -->
|
||||||
|
<key>com.apple.security.cs.allow-jit</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
|
<true/>
|
||||||
|
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
|
||||||
|
<key>com.apple.security.network.client</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -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: 1.6 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 5.9 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 8.0 KiB |
|
After Width: | Height: | Size: 5.4 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: 2.4 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 953 B After Width: | Height: | Size: 709 B |
|
After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 793 B After Width: | Height: | Size: 556 B |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 961 B |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 984 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 8.9 KiB |
@@ -0,0 +1,52 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
|
<svg
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
viewBox="0 0 512 512"
|
||||||
|
version="1.1"
|
||||||
|
id="svg5"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"><rect
|
||||||
|
width="512"
|
||||||
|
height="512"
|
||||||
|
rx="112"
|
||||||
|
ry="112"
|
||||||
|
fill="#626ED4"
|
||||||
|
id="rect1" /><line
|
||||||
|
x1="178.45584"
|
||||||
|
y1="178.45584"
|
||||||
|
x2="333.54416"
|
||||||
|
y2="333.54416"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="25"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
id="line1" /><line
|
||||||
|
x1="337.53708"
|
||||||
|
y1="174.46292"
|
||||||
|
x2="174.46294"
|
||||||
|
y2="337.53708"
|
||||||
|
stroke="#ffffff"
|
||||||
|
stroke-width="25"
|
||||||
|
stroke-opacity="0.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
id="line2" /><circle
|
||||||
|
cx="153"
|
||||||
|
cy="153"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle2" /><circle
|
||||||
|
cx="359"
|
||||||
|
cy="153"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle3" /><circle
|
||||||
|
cx="153"
|
||||||
|
cy="359"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle4" /><circle
|
||||||
|
cx="359"
|
||||||
|
cy="359"
|
||||||
|
r="36"
|
||||||
|
fill="#ffffff"
|
||||||
|
id="circle5" /></svg>
|
||||||
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 475 B |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 721 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"themes": [
|
||||||
|
{
|
||||||
|
"colors": {
|
||||||
|
"bg_active": "#373a52",
|
||||||
|
"bg_editor": "#1a1b26",
|
||||||
|
"bg_hover": "#2f3145",
|
||||||
|
"bg_primary": "#1a1b26",
|
||||||
|
"bg_secondary": "#1f2029",
|
||||||
|
"bg_tertiary": "#2a2b3d",
|
||||||
|
"border_color": "#2f3145",
|
||||||
|
"text_primary": "#c8cde4",
|
||||||
|
"text_secondary": "#6b7094"
|
||||||
|
},
|
||||||
|
"id": "custom-1782146306429",
|
||||||
|
"is_dark": true,
|
||||||
|
"name": "My theme"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ use crate::types::AiStreamEvent;
|
|||||||
|
|
||||||
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
|
||||||
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
|
||||||
|
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
|
||||||
|
|
||||||
pub fn ai_request(
|
pub fn ai_request(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -15,15 +16,19 @@ pub fn ai_request(
|
|||||||
system_prompt: String,
|
system_prompt: String,
|
||||||
user_message: String,
|
user_message: String,
|
||||||
request_id: String,
|
request_id: String,
|
||||||
|
base_url: Option<String>,
|
||||||
) {
|
) {
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap();
|
let rt = tokio::runtime::Runtime::new().unwrap();
|
||||||
rt.block_on(async {
|
rt.block_on(async {
|
||||||
|
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
|
||||||
|
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
|
||||||
let result = match provider.as_str() {
|
let result = match provider.as_str() {
|
||||||
"openai" => {
|
"openai" => {
|
||||||
stream_openai(
|
stream_openai(
|
||||||
&app,
|
&app,
|
||||||
&api_key,
|
OPENAI_API_URL,
|
||||||
|
Some(&api_key),
|
||||||
&model,
|
&model,
|
||||||
&system_prompt,
|
&system_prompt,
|
||||||
&user_message,
|
&user_message,
|
||||||
@@ -31,6 +36,38 @@ pub fn ai_request(
|
|||||||
)
|
)
|
||||||
.await
|
.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(
|
stream_anthropic(
|
||||||
&app,
|
&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(
|
async fn stream_anthropic(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
api_key: &str,
|
api_key: &str,
|
||||||
@@ -173,7 +217,6 @@ async fn stream_anthropic(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stream ended — send done
|
|
||||||
let _ = app.emit(
|
let _ = app.emit(
|
||||||
"ai-stream",
|
"ai-stream",
|
||||||
AiStreamEvent {
|
AiStreamEvent {
|
||||||
@@ -188,7 +231,8 @@ async fn stream_anthropic(
|
|||||||
|
|
||||||
async fn stream_openai(
|
async fn stream_openai(
|
||||||
app: &AppHandle,
|
app: &AppHandle,
|
||||||
api_key: &str,
|
url: &str,
|
||||||
|
api_key: Option<&str>,
|
||||||
model: &str,
|
model: &str,
|
||||||
system_prompt: &str,
|
system_prompt: &str,
|
||||||
user_message: &str,
|
user_message: &str,
|
||||||
@@ -224,10 +268,15 @@ async fn stream_openai(
|
|||||||
body["temperature"] = json!(0.7);
|
body["temperature"] = json!(0.7);
|
||||||
}
|
}
|
||||||
|
|
||||||
let response = client
|
let mut req = client
|
||||||
.post(OPENAI_API_URL)
|
.post(url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("content-type", "application/json");
|
||||||
.header("content-type", "application/json")
|
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
@@ -325,9 +374,28 @@ async fn stream_openai(
|
|||||||
Ok(())
|
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 {
|
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,
|
_ => test_anthropic(api_key, model).await,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -337,7 +405,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
|
|
||||||
let body = json!({
|
let body = json!({
|
||||||
"model": model,
|
"model": model,
|
||||||
"max_tokens": 10,
|
"max_tokens": 20,
|
||||||
"messages": [
|
"messages": [
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
@@ -365,20 +433,15 @@ 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 client = Client::new();
|
||||||
|
let is_gpt5 = model.starts_with("gpt-5");
|
||||||
|
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
|
||||||
|
|
||||||
let is_gpt5 = model.starts_with("gpt-5");
|
let body = json!({
|
||||||
let token_key = if is_gpt5 {
|
"model": model,
|
||||||
"max_completion_tokens"
|
token_key: 20,
|
||||||
} else {
|
"messages": [
|
||||||
"max_tokens"
|
|
||||||
};
|
|
||||||
|
|
||||||
let body = json!({
|
|
||||||
"model": model,
|
|
||||||
token_key: 10,
|
|
||||||
"messages": [
|
|
||||||
{
|
{
|
||||||
"role": "user",
|
"role": "user",
|
||||||
"content": "Hi"
|
"content": "Hi"
|
||||||
@@ -386,10 +449,15 @@ async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
|
|||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
let response = client
|
let mut req = client
|
||||||
.post(OPENAI_API_URL)
|
.post(url)
|
||||||
.header("Authorization", format!("Bearer {}", api_key))
|
.header("content-type", "application/json");
|
||||||
.header("content-type", "application/json")
|
|
||||||
|
if let Some(key) = api_key {
|
||||||
|
req = req.header("Authorization", format!("Bearer {}", key));
|
||||||
|
}
|
||||||
|
|
||||||
|
let response = req
|
||||||
.json(&body)
|
.json(&body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
|
|||||||
.to_string_lossy()
|
.to_string_lossy()
|
||||||
.to_string();
|
.to_string();
|
||||||
|
|
||||||
// Keep hidden dirs (.helixnotes) — we'll handle attachments separately
|
// Keep hidden dirs (.helixnotes) - we'll handle attachments separately
|
||||||
if name.starts_with('.') {
|
if name.starts_with('.') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
|
|||||||
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// Sort by name (timestamps sort lexicographically) — newest last
|
// Sort by name (timestamps sort lexicographically) - newest last
|
||||||
files.sort();
|
files.sort();
|
||||||
|
|
||||||
if files.len() as u32 > max {
|
if files.len() as u32 > max {
|
||||||
|
|||||||
@@ -4,36 +4,58 @@ mod commands;
|
|||||||
mod history;
|
mod history;
|
||||||
mod search;
|
mod search;
|
||||||
mod state;
|
mod state;
|
||||||
|
mod sync;
|
||||||
mod types;
|
mod types;
|
||||||
mod vault;
|
mod vault;
|
||||||
|
|
||||||
use state::AppState;
|
use state::AppState;
|
||||||
|
#[allow(unused_imports)]
|
||||||
|
use tauri::{Emitter, Manager};
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
use tauri::{
|
use tauri::{
|
||||||
image::Image,
|
image::Image,
|
||||||
menu::{MenuBuilder, MenuItemBuilder},
|
menu::{MenuBuilder, MenuItemBuilder},
|
||||||
tray::TrayIconBuilder,
|
tray::TrayIconBuilder,
|
||||||
Manager,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
|
||||||
|
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
|
||||||
|
}
|
||||||
|
|
||||||
|
rustls::crypto::ring::default_provider()
|
||||||
|
.install_default()
|
||||||
|
.expect("Failed to install rustls crypto provider");
|
||||||
|
|
||||||
let config = commands::load_app_config();
|
let config = commands::load_app_config();
|
||||||
|
#[cfg(desktop)]
|
||||||
let show_tray = config.show_tray_icon;
|
let show_tray = config.show_tray_icon;
|
||||||
|
#[cfg(desktop)]
|
||||||
let close_to_tray = config.close_to_tray && show_tray;
|
let close_to_tray = config.close_to_tray && show_tray;
|
||||||
let app_state = AppState::new(config);
|
let app_state = AppState::new(config);
|
||||||
|
|
||||||
|
// Inject the compile-time platform so the frontend never sniffs the (sometimes
|
||||||
|
// mobile-looking) WebKitGTK user-agent. (#63)
|
||||||
|
let platform_init = format!(
|
||||||
|
"window.__HELIX_PLATFORM__={{mobile:{},android:{},ios:{}}};",
|
||||||
|
cfg!(mobile),
|
||||||
|
cfg!(target_os = "android"),
|
||||||
|
cfg!(target_os = "ios"),
|
||||||
|
);
|
||||||
|
|
||||||
let mut builder = tauri::Builder::default()
|
let mut builder = tauri::Builder::default()
|
||||||
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
|
.plugin(
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
tauri::plugin::Builder::<tauri::Wry>::new("helix-platform")
|
||||||
let _ = window.show();
|
.js_init_script(platform_init)
|
||||||
let _ = window.unminimize();
|
.build(),
|
||||||
let _ = window.set_focus();
|
)
|
||||||
}
|
|
||||||
}))
|
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_updater::Builder::new().build())
|
|
||||||
.setup(move |app| {
|
.setup(move |app| {
|
||||||
if cfg!(debug_assertions) {
|
if cfg!(debug_assertions) {
|
||||||
app.handle().plugin(
|
app.handle().plugin(
|
||||||
@@ -43,21 +65,75 @@ 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 {
|
if show_tray {
|
||||||
setup_tray(app)?;
|
setup_tray(app)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
|
fix_macos_traffic_lights(&window);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.manage(app_state)
|
.manage(app_state)
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
commands::open_vault,
|
commands::open_vault,
|
||||||
|
commands::remove_vault,
|
||||||
commands::get_app_config,
|
commands::get_app_config,
|
||||||
commands::set_theme,
|
commands::set_theme,
|
||||||
commands::set_accent_color,
|
commands::set_accent_color,
|
||||||
|
commands::save_custom_theme,
|
||||||
|
commands::delete_custom_theme,
|
||||||
|
commands::export_custom_theme,
|
||||||
|
commands::import_custom_themes,
|
||||||
commands::set_font_size,
|
commands::set_font_size,
|
||||||
commands::set_font_family,
|
commands::set_font_family,
|
||||||
|
commands::set_line_height,
|
||||||
|
commands::set_ui_scale,
|
||||||
|
commands::set_content_width,
|
||||||
commands::get_notebooks,
|
commands::get_notebooks,
|
||||||
|
commands::count_root_notes,
|
||||||
commands::create_notebook,
|
commands::create_notebook,
|
||||||
commands::rename_notebook,
|
commands::rename_notebook,
|
||||||
commands::delete_notebook,
|
commands::delete_notebook,
|
||||||
@@ -66,19 +142,28 @@ pub fn run() {
|
|||||||
commands::read_note,
|
commands::read_note,
|
||||||
commands::save_note,
|
commands::save_note,
|
||||||
commands::create_note,
|
commands::create_note,
|
||||||
|
commands::create_daily_note,
|
||||||
commands::rename_note,
|
commands::rename_note,
|
||||||
commands::delete_note,
|
commands::delete_note,
|
||||||
commands::move_note,
|
commands::move_note,
|
||||||
commands::get_all_tags,
|
commands::get_all_tags,
|
||||||
commands::get_all_note_titles,
|
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::search_notes,
|
||||||
commands::reindex,
|
commands::reindex,
|
||||||
commands::get_trash,
|
commands::get_trash,
|
||||||
commands::restore_note,
|
commands::restore_note,
|
||||||
|
commands::restore_notebook,
|
||||||
commands::permanent_delete,
|
commands::permanent_delete,
|
||||||
commands::empty_trash,
|
commands::empty_trash,
|
||||||
commands::load_vault_state,
|
commands::load_vault_state,
|
||||||
commands::save_vault_state,
|
commands::save_vault_state,
|
||||||
|
commands::read_clipboard_image,
|
||||||
|
commands::copy_image_to_clipboard,
|
||||||
commands::save_image,
|
commands::save_image,
|
||||||
commands::save_attachment,
|
commands::save_attachment,
|
||||||
commands::get_notebook_icons,
|
commands::get_notebook_icons,
|
||||||
@@ -89,9 +174,14 @@ pub fn run() {
|
|||||||
commands::remove_quick_access,
|
commands::remove_quick_access,
|
||||||
commands::reorder_quick_access,
|
commands::reorder_quick_access,
|
||||||
commands::get_vault_stats,
|
commands::get_vault_stats,
|
||||||
|
commands::find_orphaned_attachments,
|
||||||
|
commands::trash_orphaned_attachments,
|
||||||
commands::import_obsidian,
|
commands::import_obsidian,
|
||||||
commands::open_file,
|
commands::open_file,
|
||||||
|
commands::open_url,
|
||||||
commands::copy_file_to,
|
commands::copy_file_to,
|
||||||
|
commands::write_bytes_to,
|
||||||
|
commands::copy_png_to_clipboard,
|
||||||
commands::create_backup,
|
commands::create_backup,
|
||||||
commands::list_backups,
|
commands::list_backups,
|
||||||
commands::restore_backup,
|
commands::restore_backup,
|
||||||
@@ -103,14 +193,139 @@ pub fn run() {
|
|||||||
commands::set_ai_settings,
|
commands::set_ai_settings,
|
||||||
commands::test_ai_connection,
|
commands::test_ai_connection,
|
||||||
commands::ai_ask,
|
commands::ai_ask,
|
||||||
|
commands::set_sync_settings,
|
||||||
|
commands::test_sync_connection,
|
||||||
|
commands::sync_now,
|
||||||
commands::get_install_type,
|
commands::get_install_type,
|
||||||
]);
|
commands::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 {
|
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
|
||||||
builder = builder.on_window_event(|window, event| {
|
let _ = responder.respond(
|
||||||
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
|
tauri::http::Response::builder()
|
||||||
api.prevent_close();
|
.status(400)
|
||||||
let _ = window.hide();
|
.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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -120,6 +335,7 @@ pub fn run() {
|
|||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(desktop)]
|
||||||
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
|
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
|
||||||
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
|
||||||
@@ -158,3 +374,66 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
Ok(())
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn fix_macos_traffic_lights(window: &tauri::WebviewWindow) {
|
||||||
|
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
|
||||||
|
|
||||||
|
let ns_window_ptr = match window.ns_window() {
|
||||||
|
Ok(ptr) => ptr,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
|
||||||
|
|
||||||
|
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
|
||||||
|
Some(btn) => btn,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let superview = match close.superview() {
|
||||||
|
Some(sv) => sv,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let sv_height = NSView::frame(&superview).size.height;
|
||||||
|
let btn_height = NSView::frame(&close).size.height;
|
||||||
|
if sv_height <= 0.0 || btn_height <= 0.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let centered_y = (sv_height - btn_height) / 2.0;
|
||||||
|
|
||||||
|
for btn in [&close, &miniaturize, &zoom] {
|
||||||
|
let mut frame = NSView::frame(btn);
|
||||||
|
frame.origin.y = centered_y;
|
||||||
|
btn.setFrameOrigin(frame.origin);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,12 +5,163 @@ use std::fs;
|
|||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tantivy::collector::TopDocs;
|
use tantivy::collector::TopDocs;
|
||||||
|
#[cfg(desktop)]
|
||||||
use tantivy::directory::MmapDirectory;
|
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::schema::*;
|
||||||
use tantivy::{Index, IndexWriter, TantivyDocument};
|
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
|
||||||
|
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
|
||||||
use walkdir::WalkDir;
|
use walkdir::WalkDir;
|
||||||
|
|
||||||
|
/// Bumped whenever the index schema or tokenizer changes, so the on-disk index is
|
||||||
|
/// wiped and rebuilt once on the next vault open (the index is derived from the
|
||||||
|
/// notes, so this never loses data).
|
||||||
|
const INDEX_SCHEMA_VERSION: &str = "2-cjk-bigram";
|
||||||
|
|
||||||
|
/// Per-vault search index dir in local app-data, kept out of the (possibly synced) vault.
|
||||||
|
#[cfg(desktop)]
|
||||||
|
fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(vault_path.as_bytes());
|
||||||
|
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
|
||||||
|
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True for characters in the CJK / Japanese / Korean blocks, which are written
|
||||||
|
/// without spaces between words. These get uni/bi-gram tokenized so substring
|
||||||
|
/// search works; everything else keeps the default word-splitting behaviour.
|
||||||
|
fn is_cjk(c: char) -> bool {
|
||||||
|
matches!(c as u32,
|
||||||
|
0x3400..=0x4DBF // CJK Unified Ideographs Extension A
|
||||||
|
| 0x4E00..=0x9FFF // CJK Unified Ideographs (Han)
|
||||||
|
| 0x3040..=0x309F // Hiragana
|
||||||
|
| 0x30A0..=0x30FF // Katakana
|
||||||
|
| 0xAC00..=0xD7AF // Hangul Syllables
|
||||||
|
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
|
||||||
|
| 0xFF00..=0xFFEF // Halfwidth and Fullwidth Forms
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tokenize `text` so that CJK runs become overlapping unigrams + bigrams (recall
|
||||||
|
/// over precision: a query for any substring of a CJK run will match), while runs
|
||||||
|
/// of other alphanumeric characters become a single word token (same as tantivy's
|
||||||
|
/// SimpleTokenizer, so Latin/English indexing is unchanged). Lowercasing is applied
|
||||||
|
/// by a LowerCaser filter in the analyzer, not here.
|
||||||
|
fn cjk_tokens(text: &str) -> Vec<Token> {
|
||||||
|
let mut tokens: Vec<Token> = Vec::new();
|
||||||
|
let mut position: usize = 0;
|
||||||
|
let mut cjk_run: Vec<(usize, char)> = Vec::new();
|
||||||
|
let mut word = String::new();
|
||||||
|
let mut word_start: usize = 0;
|
||||||
|
|
||||||
|
macro_rules! flush_word {
|
||||||
|
() => {{
|
||||||
|
if !word.is_empty() {
|
||||||
|
let len = word.len();
|
||||||
|
tokens.push(Token {
|
||||||
|
offset_from: word_start,
|
||||||
|
offset_to: word_start + len,
|
||||||
|
position,
|
||||||
|
text: std::mem::take(&mut word),
|
||||||
|
position_length: 1,
|
||||||
|
});
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
macro_rules! flush_cjk {
|
||||||
|
() => {{
|
||||||
|
let n = cjk_run.len();
|
||||||
|
for i in 0..n {
|
||||||
|
let (off, ch) = cjk_run[i];
|
||||||
|
tokens.push(Token {
|
||||||
|
offset_from: off,
|
||||||
|
offset_to: off + ch.len_utf8(),
|
||||||
|
position,
|
||||||
|
text: ch.to_string(),
|
||||||
|
position_length: 1,
|
||||||
|
});
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
for i in 0..n.saturating_sub(1) {
|
||||||
|
let (off1, c1) = cjk_run[i];
|
||||||
|
let (off2, c2) = cjk_run[i + 1];
|
||||||
|
let mut s = String::with_capacity(c1.len_utf8() + c2.len_utf8());
|
||||||
|
s.push(c1);
|
||||||
|
s.push(c2);
|
||||||
|
tokens.push(Token {
|
||||||
|
offset_from: off1,
|
||||||
|
offset_to: off2 + c2.len_utf8(),
|
||||||
|
position,
|
||||||
|
text: s,
|
||||||
|
position_length: 1,
|
||||||
|
});
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
cjk_run.clear();
|
||||||
|
}};
|
||||||
|
}
|
||||||
|
|
||||||
|
for (offset, c) in text.char_indices() {
|
||||||
|
if is_cjk(c) {
|
||||||
|
flush_word!();
|
||||||
|
cjk_run.push((offset, c));
|
||||||
|
} else if c.is_alphanumeric() {
|
||||||
|
flush_cjk!();
|
||||||
|
if word.is_empty() {
|
||||||
|
word_start = offset;
|
||||||
|
}
|
||||||
|
word.push(c);
|
||||||
|
} else {
|
||||||
|
flush_cjk!();
|
||||||
|
flush_word!();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush_cjk!();
|
||||||
|
flush_word!();
|
||||||
|
let _ = position; // the final flush increments position but nothing reads it after
|
||||||
|
tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A pre-computed token stream (all tokens collected up front by `cjk_tokens`).
|
||||||
|
struct PreTokenizedStream {
|
||||||
|
tokens: Vec<Token>,
|
||||||
|
idx: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenStream for PreTokenizedStream {
|
||||||
|
fn advance(&mut self) -> bool {
|
||||||
|
if self.idx < self.tokens.len() {
|
||||||
|
self.idx += 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fn token(&self) -> &Token {
|
||||||
|
&self.tokens[self.idx - 1]
|
||||||
|
}
|
||||||
|
fn token_mut(&mut self) -> &mut Token {
|
||||||
|
&mut self.tokens[self.idx - 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CjkTokenizer;
|
||||||
|
|
||||||
|
impl Tokenizer for CjkTokenizer {
|
||||||
|
type TokenStream<'a> = PreTokenizedStream;
|
||||||
|
fn token_stream<'a>(&'a mut self, text: &'a str) -> PreTokenizedStream {
|
||||||
|
PreTokenizedStream {
|
||||||
|
tokens: cjk_tokens(text),
|
||||||
|
idx: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct SearchIndex {
|
pub struct SearchIndex {
|
||||||
index: Index,
|
index: Index,
|
||||||
writer: Mutex<Option<IndexWriter>>,
|
writer: Mutex<Option<IndexWriter>>,
|
||||||
@@ -24,22 +175,75 @@ pub struct SearchIndex {
|
|||||||
|
|
||||||
impl SearchIndex {
|
impl SearchIndex {
|
||||||
pub fn new(vault_path: &str) -> Result<Self, String> {
|
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 mut schema_builder = Schema::builder();
|
||||||
let path_field = schema_builder.add_text_field("path", STRING | STORED);
|
let path_field = schema_builder.add_text_field("path", STRING | STORED);
|
||||||
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
|
// CJK-aware tokenizer for the indexed text fields (keeps Latin/English
|
||||||
let body_field = schema_builder.add_text_field("body", TEXT);
|
// behaviour identical; adds substring matching for Chinese/Japanese/Korean).
|
||||||
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
|
let cjk_indexing = TextFieldIndexing::default()
|
||||||
|
.set_tokenizer("cjk")
|
||||||
|
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
|
||||||
|
let cjk_text = TextOptions::default().set_indexing_options(cjk_indexing.clone());
|
||||||
|
let cjk_text_stored = cjk_text.clone().set_stored();
|
||||||
|
let title_field = schema_builder.add_text_field("title", cjk_text_stored.clone());
|
||||||
|
let body_field = schema_builder.add_text_field("body", cjk_text);
|
||||||
|
let tags_field = schema_builder.add_text_field("tags", cjk_text_stored);
|
||||||
let schema = schema_builder.build();
|
let schema = schema_builder.build();
|
||||||
|
|
||||||
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
|
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
|
||||||
let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?;
|
// 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
|
// Remove the old in-vault index so it stops syncing.
|
||||||
.writer(50_000_000)
|
let hn = helixnotes_dir(vault_path);
|
||||||
.map_err(|e| e.to_string())?;
|
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 {
|
Ok(Self {
|
||||||
index,
|
index,
|
||||||
@@ -62,18 +266,20 @@ impl SearchIndex {
|
|||||||
|
|
||||||
for entry in WalkDir::new(vault_path)
|
for entry in WalkDir::new(vault_path)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_entry(|e| {
|
||||||
.filter(|e| {
|
|
||||||
let p = e.path();
|
let p = e.path();
|
||||||
p.is_file()
|
!p.starts_with(&hn_dir)
|
||||||
&& p.extension().and_then(|x| x.to_str()) == Some("md")
|
|
||||||
&& !p.starts_with(&hn_dir)
|
|
||||||
&& !p
|
&& !p
|
||||||
.file_name()
|
.file_name()
|
||||||
.and_then(|n| n.to_str())
|
.and_then(|n| n.to_str())
|
||||||
.map(|n| n.starts_with('.'))
|
.map(|n| n.starts_with('.'))
|
||||||
.unwrap_or(false)
|
.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()) {
|
if let Ok(raw) = fs::read_to_string(entry.path()) {
|
||||||
let filename = entry.file_name().to_string_lossy().to_string();
|
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 reader = self.index.reader().map_err(|e| e.to_string())?;
|
||||||
let searcher = reader.searcher();
|
let searcher = reader.searcher();
|
||||||
|
|
||||||
let query_parser =
|
let fields = vec![self.title_field, self.body_field, self.tags_field];
|
||||||
QueryParser::for_index(&self.index, 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
|
// For each term, OR queries across all fields, then AND all terms together.
|
||||||
.parse_query(query_str)
|
// CJK tokens (uni/bigrams) already encode segmentation -> exact term match.
|
||||||
.map_err(|e| e.to_string())?;
|
// 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
|
let top_docs = searcher
|
||||||
.search(&query, &TopDocs::with_limit(limit))
|
.search(&query, &TopDocs::with_limit(limit))
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ use crate::types::AppConfig;
|
|||||||
use notify::RecommendedWatcher;
|
use notify::RecommendedWatcher;
|
||||||
use std::sync::atomic::AtomicBool;
|
use std::sync::atomic::AtomicBool;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
pub struct AppState {
|
pub struct AppState {
|
||||||
pub config: Mutex<AppConfig>,
|
pub config: Mutex<AppConfig>,
|
||||||
pub search_index: Mutex<Option<SearchIndex>>,
|
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
|
||||||
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
pub watcher: Mutex<Option<RecommendedWatcher>>,
|
||||||
pub importing: AtomicBool,
|
pub importing: AtomicBool,
|
||||||
|
pub syncing: AtomicBool,
|
||||||
|
pub pending_open_file: Mutex<Option<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -18,6 +21,8 @@ impl AppState {
|
|||||||
search_index: Mutex::new(None),
|
search_index: Mutex::new(None),
|
||||||
watcher: Mutex::new(None),
|
watcher: Mutex::new(None),
|
||||||
importing: AtomicBool::new(false),
|
importing: AtomicBool::new(false),
|
||||||
|
syncing: AtomicBool::new(false),
|
||||||
|
pending_open_file: Mutex::new(None),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,726 @@
|
|||||||
|
// 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;
|
||||||
|
// 403 some servers (openmediavault et al.) return 403 instead of 405.
|
||||||
|
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
|
||||||
|
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,
|
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)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct NotebookEntry {
|
pub struct NotebookEntry {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
@@ -36,10 +50,48 @@ pub struct NoteContent {
|
|||||||
pub raw: String,
|
pub raw: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
pub struct VaultConfig {
|
pub struct VaultConfig {
|
||||||
pub path: String,
|
pub path: String,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
|
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_open: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_change: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_interval_minutes: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync_time: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CustomThemeColors {
|
||||||
|
pub bg_primary: String,
|
||||||
|
pub bg_secondary: String,
|
||||||
|
pub bg_tertiary: String,
|
||||||
|
pub bg_hover: String,
|
||||||
|
pub bg_active: String,
|
||||||
|
pub bg_editor: String,
|
||||||
|
pub text_primary: String,
|
||||||
|
pub text_secondary: String,
|
||||||
|
pub border_color: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct CustomTheme {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub is_dark: bool,
|
||||||
|
pub colors: CustomThemeColors,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -54,9 +106,21 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub font_family: Option<String>,
|
pub font_family: Option<String>,
|
||||||
#[serde(default)]
|
#[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,
|
pub compact_notes: bool,
|
||||||
|
#[serde(default = "default_true")]
|
||||||
|
pub show_note_dates: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub time_format: String,
|
pub time_format: String,
|
||||||
|
#[serde(default = "default_week_start")]
|
||||||
|
pub week_start: String,
|
||||||
|
#[serde(default = "default_daily_title_format")]
|
||||||
|
pub daily_title_format: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub gpu_acceleration: bool,
|
pub gpu_acceleration: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -70,6 +134,20 @@ pub struct AppConfig {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub hide_title_in_body: bool,
|
pub hide_title_in_body: bool,
|
||||||
#[serde(default)]
|
#[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,
|
pub backup_enabled: bool,
|
||||||
#[serde(default = "default_backup_frequency")]
|
#[serde(default = "default_backup_frequency")]
|
||||||
pub backup_frequency: String,
|
pub backup_frequency: String,
|
||||||
@@ -89,6 +167,14 @@ pub struct AppConfig {
|
|||||||
pub ai_api_key: Option<String>,
|
pub ai_api_key: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub openai_api_key: Option<String>,
|
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")]
|
#[serde(default = "default_ai_model")]
|
||||||
pub ai_model: String,
|
pub ai_model: String,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -101,12 +187,37 @@ pub struct AppConfig {
|
|||||||
pub close_to_tray: bool,
|
pub close_to_tray: bool,
|
||||||
#[serde(default = "default_true")]
|
#[serde(default = "default_true")]
|
||||||
pub enable_wiki_links: bool,
|
pub enable_wiki_links: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub restore_last_session: bool,
|
||||||
|
// DEPRECATED: WebDAV sync moved to per-vault VaultConfig. Kept for one release to migrate old configs.
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_provider: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_url: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_username: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub webdav_password: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_open: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_on_change: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sync_interval_minutes: u32,
|
||||||
|
#[serde(default)]
|
||||||
|
pub last_sync_time: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub custom_themes: Vec<CustomTheme>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_true() -> bool {
|
fn default_true() -> bool {
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn default_outline_width() -> f64 {
|
||||||
|
220.0
|
||||||
|
}
|
||||||
|
|
||||||
fn default_pdf_height() -> u32 {
|
fn default_pdf_height() -> u32 {
|
||||||
600
|
600
|
||||||
}
|
}
|
||||||
@@ -123,12 +234,20 @@ fn default_title_mode() -> String {
|
|||||||
"input".to_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 {
|
fn default_max_versions() -> u32 {
|
||||||
20
|
20
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_ai_model() -> String {
|
fn default_ai_model() -> String {
|
||||||
"claude-sonnet-4-5-20250929".to_string()
|
"claude-sonnet-4-6".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
@@ -140,14 +259,27 @@ impl Default for AppConfig {
|
|||||||
accent_color: None,
|
accent_color: None,
|
||||||
font_size: None,
|
font_size: None,
|
||||||
font_family: None,
|
font_family: None,
|
||||||
|
line_height: None,
|
||||||
|
ui_scale: None,
|
||||||
|
content_width: None,
|
||||||
compact_notes: false,
|
compact_notes: false,
|
||||||
|
show_note_dates: true,
|
||||||
time_format: "relative".to_string(),
|
time_format: "relative".to_string(),
|
||||||
|
week_start: "monday".to_string(),
|
||||||
|
daily_title_format: "localized".to_string(),
|
||||||
gpu_acceleration: true,
|
gpu_acceleration: true,
|
||||||
autostart: false,
|
autostart: false,
|
||||||
pdf_preview: false,
|
pdf_preview: false,
|
||||||
pdf_height: 600,
|
pdf_height: 600,
|
||||||
title_mode: "input".to_string(),
|
title_mode: "input".to_string(),
|
||||||
hide_title_in_body: false,
|
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_enabled: false,
|
||||||
backup_frequency: "24h".to_string(),
|
backup_frequency: "24h".to_string(),
|
||||||
backup_max_count: 10,
|
backup_max_count: 10,
|
||||||
@@ -158,12 +290,26 @@ impl Default for AppConfig {
|
|||||||
ai_provider: None,
|
ai_provider: None,
|
||||||
ai_api_key: None,
|
ai_api_key: None,
|
||||||
openai_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,
|
ai_writing_style: None,
|
||||||
default_view_mode: false,
|
default_view_mode: false,
|
||||||
show_tray_icon: false,
|
show_tray_icon: false,
|
||||||
close_to_tray: false,
|
close_to_tray: false,
|
||||||
enable_wiki_links: true,
|
enable_wiki_links: true,
|
||||||
|
restore_last_session: false,
|
||||||
|
sync_provider: None,
|
||||||
|
webdav_url: None,
|
||||||
|
webdav_username: None,
|
||||||
|
webdav_password: None,
|
||||||
|
sync_on_open: false,
|
||||||
|
sync_on_change: false,
|
||||||
|
sync_interval_minutes: 0,
|
||||||
|
last_sync_time: None,
|
||||||
|
custom_themes: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -173,9 +319,35 @@ pub struct VaultState {
|
|||||||
pub last_open_note: Option<String>,
|
pub last_open_note: Option<String>,
|
||||||
pub sidebar_width: f64,
|
pub sidebar_width: f64,
|
||||||
pub notelist_width: f64,
|
pub notelist_width: f64,
|
||||||
|
#[serde(default = "default_outline_width")]
|
||||||
|
pub outline_width: f64,
|
||||||
pub sidebar_collapsed: bool,
|
pub sidebar_collapsed: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
|
pub notelist_collapsed: bool,
|
||||||
|
#[serde(default)]
|
||||||
pub collapsed_notebooks: Vec<String>,
|
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 {
|
impl Default for VaultState {
|
||||||
@@ -184,8 +356,21 @@ impl Default for VaultState {
|
|||||||
last_open_note: None,
|
last_open_note: None,
|
||||||
sidebar_width: 220.0,
|
sidebar_width: 220.0,
|
||||||
notelist_width: 280.0,
|
notelist_width: 280.0,
|
||||||
|
outline_width: default_outline_width(),
|
||||||
sidebar_collapsed: false,
|
sidebar_collapsed: false,
|
||||||
|
notelist_collapsed: false,
|
||||||
collapsed_notebooks: Vec::new(),
|
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(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,6 +393,9 @@ pub struct FileEvent {
|
|||||||
pub struct ImportResult {
|
pub struct ImportResult {
|
||||||
pub files_converted: u64,
|
pub files_converted: u64,
|
||||||
pub links_converted: u64,
|
pub links_converted: u64,
|
||||||
|
pub frontmatter_normalized: u64,
|
||||||
|
pub syntax_converted: u64,
|
||||||
|
pub attachments_moved: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@@ -245,3 +433,34 @@ pub struct NoteTitleEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
pub path: 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>,
|
||||||
|
}
|
||||||
|
|||||||