Compare commits

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

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/85
2026-06-08 10:50:39 +02:00
Yuri Karamian bd59f6dbee Enhance calendars: unify daily and Tasks calendars, remember List/Calendar choice per vault 2026-06-08 02:53:34 +02:00
Yuri Karamian 26fa22afaa Add task management: aggregated Tasks view (list + calendar) with inline due/priority, check off, set from row, and drag-to-reschedule (#62) 2026-06-08 02:34:19 +02:00
Yuri Karamian e2702bdd51 Create new notes in the open note's notebook when not in a notebook view 2026-06-08 01:20:21 +02:00
Yuri Karamian 0d0ecee06e Add 'Restore last session on launch' setting (reopen last note + view) 2026-06-08 01:12:32 +02:00
Yuri Karamian 6115ad4ab6 Add 'Show dates' setting to toggle the note-list date 2026-06-08 01:00:25 +02:00
Yuri Karamian a400eeefed Store search index in local app-data, not the synced vault (fixes OneDrive churn) 2026-06-08 00:52:45 +02:00
Yuri Karamian f19ddee17d Persist note-list sort mode across restarts; date follows sort + tooltip 2026-06-08 00:47:05 +02:00
Yuri Karamian 393505e1ac Note list date follows sort mode + tooltip 2026-06-08 00:42:36 +02:00
Yuri Karamian b87e8b8a38 Treat Unfiled Notes as root when creating a new notebook 2026-06-08 00:32:43 +02:00
Yuri Karamian 5b62302b3a Refresh notebook note counts immediately after creating a note 2026-06-08 00:31:55 +02:00
Yuri Karamian ee0ffdf121 Allow creating a top-level notebook while a notebook is selected (clearable parent chip) 2026-06-08 00:26:39 +02:00
Yuri Karamian 89eea8e71b Add color swatch previews and /color insert 2026-06-08 00:04:38 +02:00
Yuri Karamian f27512c7ee Enhance Sync settings section 2026-06-07 23:41:20 +02:00
Yuri Karamian eafd2b0a24 Add WebDAV sync provider (manual + auto-sync, title-bar button) 2026-06-07 23:25:47 +02:00
Yuri Karamian aeafd55491 Add WebDAV sync provider (manual + auto-sync, top-bar button) 2026-06-07 23:14:04 +02:00
Yuri Karamian ccc5e3b7c6 Fix doubled safe-area inset causing top/bottom gap on Android 2026-06-07 19:11:59 +02:00
Yuri Karamian a28e514ed4 Fix Windows wiki-link/graph showing duplicate and old-version notes (path separators) 2026-06-07 18:55:12 +02:00
Yuri Karamian 7c94370e48 Add CJK (Chinese/Japanese/Korean) tokenizer for full-text search 2026-06-07 17:44:05 +02:00
Yuri Karamian ab39fc2ed6 Update Anthropic/OpenAI model lists (Opus 4.8, GPT-5.4/5.5) 2026-06-07 17:23:11 +02:00
Yuri Karamian 621e2b31e0 Add date/time insertion (slash, toolbar, context menu; ISO format) 2026-06-07 16:18:43 +02:00
Yuri Karamian ca3900815b Allow toggling task-list checkboxes in View Mode 2026-06-07 12:54:13 +02:00
Yuri Karamian b36ce9ed5b Fix code auto-linkify (#80) and currency text parsed as math (#81) 2026-06-06 14:36:52 +02:00
Yuri Karamian 59677577ec Fix unreliable window resize on Linux with custom resize handles (#77) 2026-06-05 18:27:08 +02:00
Yuri Karamian 71a0b9c0a0 Show mobile AI button for all configured providers 2026-06-05 11:21:11 +02:00
qyrhalandmidhun kumar 3876b7db96 Fix issue of ai features not appearing when configured (#78) (#79)
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/79
2026-06-05 11:17:56 +02:00
blake 0436136533 Added Solarized, Github, Nord, Tokyo Night, Dracula, and Catppuccin themes. (#76)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/76
2026-06-04 21:49:49 +02:00
Yuri Karamian 139ecc8176 Add Interface Scale setting to zoom the whole app (80-200%) 2026-06-04 19:27:00 +02:00
qyrhalandmidhun kumar 4b82d51f6c Added openai compatible v1 completions and ollama api key compatibility (#73) (#74)
Added:
    - v1 completions (OpenAI compatible) and testing connection
    - Add API key handling for Ollama and v1 completions if required
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/74
2026-06-03 18:49:11 +02:00
Yuri Karamian 8ceb44df78 Remove unused fastlane metadata 2026-06-03 14:43:46 +02:00
Yuri Karamian 3b3e7fbbe6 Merge branch 'main' of ssh://codeberg.org/ArkHost/HelixNotes 2026-06-03 14:23:29 +02:00
Yuri Karamian 7cbd112585 Cross-platform groundwork: platform detection, iOS config, Windows drag-drop fix, Android 10 storage fix (#72), bundled fonts 2026-06-03 14:11:10 +02:00
doceys d275b0e783 Build on NixOS (#49)
feat: add Nix flake for NixOS/darwin builds (#49)
2026-04-30 11:31:47 +02:00
Yuri Karamian fedbdfd2a1 update README.md 2026-04-27 23:23:40 +02:00
Yuri Karamian 7f2aae39d1 v1.2.9: mermaid diagrams, math editor, external viewer, manual notebook sort, heading hotkeys 2026-04-27 20:33:59 +02:00
Yuri Karamian 90f6f315f9 v1.2.9: mermaid diagrams, math editor, external viewer, manual notebook sort, heading hotkeys 2026-04-27 20:22:40 +02:00
Yuri Karamian 9c092e90f1 Added a filled-star indicator to notes in Quick Access 2026-04-18 02:13:15 +02:00
Yuri Karamian ed2fc87e86 update README.md 2026-03-27 15:04:51 +01:00
Yuri Karamian 7624c42e02 v1.2.8 - fix url links, tab indent, todo state, graph rework, collapsible sections, nav history, page breaks, folder/tag display 2026-03-27 14:05:59 +01:00
Yuri Karamian d46012b650 update README.md v1.2.7 2026-03-19 21:34:52 +01:00
Yuri Karamian 5d11ae6ea0 update README.md 2026-03-19 20:45:53 +01:00
Yuri Karamian bc372993c4 v1.2.7 - prefix search, keyboard shortcut fixes, GNOME AppImage fix 2026-03-19 19:19:26 +01:00
Yuri Karamian 4897703111 update README.md 2026-03-19 01:39:20 +01:00
Yuri Karamian 1bc067d470 v1.2.6 - unfiled notes, keyboard shortcuts, Android fixes 2026-03-19 00:36:42 +01:00
Yuri Karamian 565688b284 Restore trashed notebooks to their original location instead of vault root 2026-03-17 22:48:24 +01:00
Yuri Karamian e87a2edc00 Add keyboard shortcuts for sidebar, theme, focus mode, read-only, and fullscreen toggles 2026-03-17 22:37:48 +01:00
Yuri Karamian 9f595212dd add unfiled notes 2026-03-17 21:45:51 +01:00
Yuri Karamian 79f16b454a update README.md 2026-03-12 01:06:53 +01:00
Yuri Karamian 7be1b56b7c v1.2.5 - calendar view for daily notes, trash notebook restore, attachment/link fixes 2026-03-12 00:12:45 +01:00
Yuri Karamian 0b17a7c458 update README.md 2026-03-09 18:03:50 +01:00
Yuri Karamian 6c2bc72fbb v1.2.4 - proxy external images through imgproxy protocol for WebKitGTK compat, hide copy/open buttons for external images 2026-03-09 15:58:58 +01:00
Yuri Karamian e67ec21319 update README.md 2026-03-08 19:13:01 +01:00
Yuri Karamian e87c8cbe1d update README.md 2026-03-06 21:49:44 +01:00
Yuri Karamian 4fac6d05a7 update README.md 2026-03-06 21:46:45 +01:00
Yuri Karamian fffea45c50 v1.2.3 2026-03-06 20:20:15 +01:00
Yuri Karamian eb88955e20 v1.2.2 2026-03-03 13:58:12 +01:00
Yuri Karamian 5180bd5a38 v1.2.2 - Localized daily note titles, Android light mode status bar fix, Print/Export to PDF 2026-03-03 13:28:04 +01:00
Yuri Karamian a6b9037ae0 Add fastlane metadata for F-Droid / IzzyOnDroid 2026-03-01 00:56:06 +01:00
Yuri Karamian 88251a503c remove fdroid template data 2026-03-01 00:29:34 +01:00
Yuri Karamian 283340a0f4 update README.md 2026-03-01 00:11:17 +01:00
Yuri Karamian 545d4aec3b update README.md 2026-03-01 00:07:54 +01:00
Yuri Karamian 9315151a64 update README.md 2026-02-28 23:53:21 +01:00
133 changed files with 10622 additions and 1090 deletions
+17 -1
View File
@@ -1,5 +1,10 @@
node_modules
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
package-lock.json
yarn.lock
bun.lockb
# Output
.output
.vercel
@@ -8,9 +13,20 @@ node_modules
/.svelte-kit
/build
# Nix
/result
# Tauri
/src-tauri/target
/src-tauri/gen
# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested
# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out.
/src-tauri/gen/*
!/src-tauri/gen/android/
# Never commit signing secrets (nested ignore misses keystore.properties)
/src-tauri/gen/android/keystore.properties
/src-tauri/gen/android/key.properties
# OS
.DS_Store
+83 -26
View File
@@ -1,11 +1,16 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.9-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.2.0)
## Download (v1.2.9)
### Linux
@@ -21,25 +26,83 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### NixOS
<details>
<summary>flake.nix</summary>
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
helix-notes = {
url = "git+https://codeberg.org/ArkHost/HelixNotes";
# inputs.nixpkgs.follows = "nixpkgs";
}
};
outputs = {
nixpkgs,
helix-notes,
...
}: let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
system = system;
specialArgs = { inherit helix-notes; };
modules = [
/path/to/configuration.nix
];
};
};
}
```
</details>
<details>
<summary>configuration.nix</summary>
```nix
{
config,
lib,
pkgs,
helix-notes,
...
}:
{
users.users.<USERNAME> = {
packages = with pkgs; [
(helix-notes.packages.${pkgs.stdenv.hostPlatform.system}.default)
];
};
}
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.0/HelixNotes_1.2.0_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.9/HelixNotes_1.2.9_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.0/HelixNotes_1.2.0_amd64.deb) Ubuntu 22.04+
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.0/HelixNotes_1.2.0_x64-setup.exe) Windows 10/11
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.0/HelixNotes_1.2.0_x64.dmg) — macOS (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://download.helixnotes.com/releases/v1.2.0/HelixNotes_1.2.0_android.apk) — Tested on Android 16
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_android.apk)
---
@@ -47,26 +110,20 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features
- **Markdown editor** with rich formatting toolbar, slash commands, source mode toggle, and code syntax highlighting
- **Wiki-links** — link notes with `[[Note Title]]` syntax
- **Graph view** — visualize connections between your notes
- **Outline panel** — heading navigation for long notes
- **Full-text search** powered by Tantivy
- **Math support** — KaTeX rendering for inline and block equations
- **Daily notes** — one-click button to create or open today's note
- **AI writing tools** — improve, summarize, translate, and more (Ollama / Anthropic / OpenAI)
- **Version history** — per-note snapshots with diff view
- **Backups** — automatic zip-based vault backups
- **PDF preview** — inline rendering of embedded PDFs
- **Tag management** — organize notes with tags, bulk edit from context menu
- **Drag-and-drop** — move notes between notebooks, reorganize notebooks by dragging
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Multi-window** — open notes in separate windows (right-click → "Open in New Window")
- **File associations** — open .md files from your file manager directly in HelixNotes
- **Themes** — light/dark mode with customizable accent colors, fonts, and line height
- **Focus mode** — distraction-free writing
- **View mode** — read-only toggle for distraction-free reading
- **Local** — everything stays on your machine
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy)
- Outline panel, daily notes with calendar view, tags, drag-and-drop
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
- Manual notebook sorting (drag to reorder above, into, or below)
- External `.md` viewer mode with import-to-vault flow
- PDF preview, Obsidian import, "Show in File Manager"
- AI writing tools (Ollama / Anthropic / OpenAI)
- Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode
- Themes, accent colors, fonts
- Local files, no cloud
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
-174
View File
@@ -1,174 +0,0 @@
Categories:
- Writing
- Office
License: AGPL-3.0-or-later
AuthorName: ArkHost
AuthorWebSite: https://helixnotes.com
SourceCode: https://codeberg.org/ArkHost/HelixNotes
IssueTracker: https://codeberg.org/ArkHost/HelixNotes/issues
Changelog: https://codeberg.org/ArkHost/HelixNotes/releases
AutoName: HelixNotes
RepoType: git
Repo: https://codeberg.org/ArkHost/HelixNotes.git
Builds:
- versionName: 1.2.0
versionCode: 10200001
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/arm64/release/app-arm64-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add aarch64-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200002
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/arm/release/app-arm-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add armv7-linux-androideabi
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200003
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/x86/release/app-x86-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add i686-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
- versionName: 1.2.0
versionCode: 10200004
commit: v1.2.0
timeout: 10800
sudo:
- apt-get update
- apt-get install -y make pkg-config libssl-dev
output: src-tauri/gen/android/app/build/outputs/apk/x86_64/release/app-x86_64-release-unsigned.apk
srclibs:
- rustup@1.28.1
- esbuild@v0.25.0
prebuild:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- $$rustup$$/rustup-init.sh -y --no-modify-path
- export PATH="$$CARGO_HOME$$/bin:$PATH"
- rustup target add x86_64-linux-android
- cargo install tauri-cli --version ^2.10.0 --locked
- cd $$esbuild$$
- GOPATH="$PWD/gopath" go build -o esbuild-bin ./cmd/esbuild
- cd $$helixnotes$$
scandelete:
- src-tauri/gen/android/.gradle
build:
- export RUSTUP_HOME="$$rustup$$/rustup"
- export CARGO_HOME="$$rustup$$/cargo"
- export PATH="$$CARGO_HOME$$/bin:$$esbuild$$:$PATH"
- ln -sf $$esbuild$$/esbuild-bin $$esbuild$$/esbuild
- export NDK_HOME="$$NDK$$"
- export ANDROID_HOME="$$SDK$$"
- corepack enable
- corepack prepare pnpm@latest --activate
- pnpm install --frozen-lockfile
- cargo tauri android build --apk --split-per-abi --ci
ndk: 27.0.12077973
gradle:
- yes
AllowedAPKSigningKeys: TODO_YOUR_APK_SIGNING_CERT_SHA256
AutoUpdateMode: Version
UpdateCheckMode: Tags ^v[\d.]+$
CurrentVersion: 1.2.0
CurrentVersionCode: 10200004
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777268161,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+76
View File
@@ -0,0 +1,76 @@
{
description = "HelixNotes (local Markdown notes app) with Nix flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name;
version = cargoToml.package.version;
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
inherit pname version;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
};
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
pnpmConfigHook
pnpm
nodejs
pkg-config
jq
moreutils
wrapGAppsHook3
];
buildInputs = with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
];
cargoRoot = "src-tauri";
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
# Deactivate the upstream update mechanism
postPatch = ''
jq '
.bundle.createUpdaterArtifacts = false |
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
' \
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
'';
meta = with pkgs.lib; {
description = "HelixNotes desktop application";
homepage = "https://helixnotes.com/";
license = licenses.agpl3Plus;
platforms = platforms.linux ++ platforms.darwin;
mainProgram = "helixnotes";
};
};
formatter = pkgs.alejandra;
});
}
+3 -2
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.2.1",
"version": "1.3.0",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -65,6 +65,7 @@
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1"
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
}
}
+937
View File
File diff suppressed because it is too large Load Diff
+39 -3
View File
@@ -633,6 +633,7 @@ dependencies = [
"iana-time-zone",
"js-sys",
"num-traits",
"pure-rust-locales",
"serde",
"wasm-bindgen",
"windows-link 0.2.1",
@@ -1849,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.2.1"
version = "1.3.0"
dependencies = [
"arboard",
"chrono",
@@ -1860,6 +1861,7 @@ dependencies = [
"log",
"notify",
"png 0.17.16",
"quick-xml 0.36.2",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -1867,6 +1869,8 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"sys-locale",
"tantivy",
"tauri",
"tauri-build",
@@ -1878,6 +1882,7 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"urlencoding",
"uuid",
"walkdir",
"zip 2.4.2",
@@ -3416,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -3589,6 +3594,12 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "pure-rust-locales"
version = "0.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "869675ad2d7541aea90c6d88c81f46a7f4ea9af8cd0395d38f11a95126998a0d"
[[package]]
name = "pxfm"
version = "0.1.27"
@@ -3604,6 +3615,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -3930,6 +3950,7 @@ checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147"
dependencies = [
"base64 0.22.1",
"bytes",
"futures-channel",
"futures-core",
"futures-util",
"http",
@@ -4770,6 +4791,15 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "sys-locale"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4"
dependencies = [
"libc",
]
[[package]]
name = "system-deps"
version = "6.2.2"
@@ -5893,6 +5923,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -6204,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.38.4",
"quote",
]
+8 -4
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.2.1"
version = "1.3.0"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -26,7 +26,8 @@ serde_yaml = "0.9"
log = "0.4"
notify = "8"
walkdir = "2"
chrono = { version = "0.4", features = ["serde"] }
chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sys-locale = "0.3"
uuid = { version = "1", features = ["v4"] }
tantivy = "0.22"
gray_matter = "0.2"
@@ -34,11 +35,14 @@ tokio = { version = "1", features = ["full"] }
dirs = "6"
regex = "1"
zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
+3
View File
@@ -7,11 +7,14 @@
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default",
+2 -1
View File
@@ -6,6 +6,7 @@
"windows": ["main"],
"permissions": [
"updater:default",
"window-state:default"
"window-state:default",
"core:webview:allow-set-webview-zoom"
]
}
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+19
View File
@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/java/com/helixnotes/app/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,92 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.helixnotes.app"
if (keystoreProperties.containsKey("storeFile")) {
signingConfigs {
create("release") {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.helixnotes.app"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
if (signingConfigs.names.contains("release")) {
signingConfig = signingConfigs.getByName("release")
}
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+22
View File
@@ -0,0 +1,22 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Preserve JS interface annotations
-keepattributes JavascriptInterface
# Keep our custom WebView JS bridge and all its public methods
-keep class com.helixnotes.app.StorageBridge {
public *;
}
# Keep MainActivity public methods (called from StorageBridge)
-keep class com.helixnotes.app.MainActivity {
public *;
}
# Ignore missing java.beans classes (referenced by Jackson but not available on Android)
-dontwarn java.beans.**
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:theme="@style/Theme.helixnotes"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,150 @@
package com.helixnotes.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.MimeTypeMap
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
class MainActivity : TauriActivity() {
private var webView: WebView? = null
companion object {
private const val STORAGE_PERMISSION_REQUEST_CODE = 1001
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
this.webView = webView
webView.addJavascriptInterface(StorageBridge(this), "Android")
}
override fun onResume() {
super.onResume()
if (hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun hasStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+): "All files access" special permission.
Environment.isExternalStorageManager()
} else {
// Android 7-10 (API 24-29): legacy runtime storage permission.
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
}
fun requestStoragePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) return
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
try {
val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
startActivity(fallback)
} catch (_: Exception) {
val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
details.data = Uri.parse("package:$packageName")
startActivity(details)
}
}
} else {
// Android 7-10: request the legacy runtime permission directly.
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
STORAGE_PERMISSION_REQUEST_CODE
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun prepareVaultDir(path: String): Boolean {
return try {
val dir = File(path)
dir.mkdirs()
File(dir, ".helixnotes/trash").mkdirs()
File(dir, ".helixnotes/attachments").mkdirs()
dir.exists() && dir.canWrite()
} catch (e: Exception) {
false
}
}
fun openFile(path: String) {
try {
val file = File(path)
if (!file.exists()) return
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("HelixNotes", "Failed to open file: $path", e)
}
}
}
class StorageBridge(private val activity: MainActivity) {
@JavascriptInterface
fun hasStoragePermission(): Boolean {
return activity.hasStoragePermission()
}
@JavascriptInterface
fun requestStoragePermission() {
activity.runOnUiThread {
activity.requestStoragePermission()
}
}
@JavascriptInterface
fun prepareVaultDir(path: String): Boolean {
return activity.prepareVaultDir(path)
}
@JavascriptInterface
fun openFile(path: String) {
activity.runOnUiThread {
activity.openFile(path)
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#5B6ABF"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (dark mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#1a1b26</item>
<item name="android:navigationBarColor">#1a1b26</item>
<item name="android:statusBarColor">#1a1b26</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">HelixNotes</string>
<string name="main_activity_title">HelixNotes</string>
</resources>
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (light mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#ffffff</item>
<item name="android:navigationBarColor">#ffffff</item>
<item name="android:statusBarColor">#ffffff</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """pnpm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.6 KiB

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1023 B

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.1 KiB

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 735 B

After

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.2 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 7.0 KiB

+192 -3
View File
@@ -21,6 +21,8 @@ pub fn ai_request(
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
let result = match provider.as_str() {
"openai" => {
stream_openai(
@@ -40,7 +42,7 @@ pub fn ai_request(
stream_openai(
&app,
&url,
None,
key_opt,
&model,
&system_prompt,
&user_message,
@@ -48,6 +50,24 @@ pub fn ai_request(
)
.await
}
"openai_compatible" => {
let url = base_url.as_deref().unwrap_or("");
if url.is_empty() {
Err("No base URL configured for OpenAI Compatible provider".to_string())
} else {
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
stream_completions(
&app,
&url,
key_opt,
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
}
_ => {
stream_anthropic(
&app,
@@ -190,7 +210,6 @@ async fn stream_anthropic(
}
}
// Stream ended — send done
let _ = app.emit(
"ai-stream",
AiStreamEvent {
@@ -354,12 +373,21 @@ pub async fn test_connection(
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
test_openai(&url, None, model).await
test_openai(&url, key_opt, model).await
}
"openai_compatible" => {
let url = base_url.unwrap_or("");
if url.is_empty() {
return Err("No base URL configured for OpenAI Compatible provider".to_string());
}
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
test_completions(&url, key_opt, model).await
}
_ => test_anthropic(api_key, model).await,
}
@@ -398,6 +426,167 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
}
}
async fn stream_completions(
app: &AppHandle,
url: &str,
api_key: Option<&str>,
model: &str,
system_prompt: &str,
user_message: &str,
_request_id: &str,
) -> Result<(), String> {
let client = Client::new();
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
let body = json!({
"model": model,
"prompt": prompt,
"max_tokens": 4096,
"stream": true,
"temperature": 0.7
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
return Err(format!("API error {}: {}", status, body_text));
}
// Parse SSE stream
use futures::StreamExt;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete SSE events from buffer
while let Some(event_end) = buffer.find("\n\n") {
let event_str = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
for line in event_str.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
// Completions streaming: choices[0].text
if let Some(text) = parsed["choices"][0]["text"].as_str() {
if !text.is_empty() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "text".to_string(),
text: Some(text.to_string()),
error: None,
},
);
}
}
// Check finish_reason
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
if reason == "stop" || reason == "length" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
}
// Check for error in stream
if let Some(err) = parsed["error"]["message"].as_str() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "error".to_string(),
text: None,
error: Some(err.to_string()),
},
);
return Err(err.to_string());
}
}
}
}
}
}
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
Ok(())
}
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let body = json!({
"model": model,
"prompt": "Hi",
"max_tokens": 10,
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
if response.status().is_success() {
Ok("Connection successful".to_string())
} else {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
Err(format!("API error {}: {}", status, body_text))
}
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
+1 -1
View File
@@ -207,7 +207,7 @@ pub fn restore_backup(vault_path: &str, backup_path: &str) -> Result<(), String>
.to_string_lossy()
.to_string();
// Keep hidden dirs (.helixnotes) we'll handle attachments separately
// Keep hidden dirs (.helixnotes) - we'll handle attachments separately
if name.starts_with('.') {
continue;
}
+604 -76
View File
@@ -11,18 +11,18 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?;
// Initialize search index rebuild in background on Android (FUSE is slow)
// Initialize search index - rebuild in background on mobile (sandboxed FS is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")]
#[cfg(mobile)]
{
let search_bg = search.clone();
let vault = path.clone();
std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault);
log::info!("Android: search index rebuild complete");
log::info!("mobile: search index rebuild complete");
});
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
search.rebuild(&path)?;
}
@@ -103,6 +103,14 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
Ok(())
}
#[tauri::command]
pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.ui_scale = Some(scale);
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ──
#[tauri::command]
@@ -112,6 +120,13 @@ pub fn get_notebooks(state: State<'_, AppState>) -> Result<Vec<NotebookEntry>, S
operations::scan_notebooks(vault_path)
}
#[tauri::command]
pub fn count_root_notes(state: State<'_, AppState>) -> Result<usize, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::count_root_notes(vault_path)
}
#[tauri::command]
pub fn create_notebook(
state: State<'_, AppState>,
@@ -201,9 +216,11 @@ pub fn move_notebook(
#[tauri::command]
pub fn delete_notebook(state: State<'_, AppState>, path: String) -> Result<(), String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::delete_notebook(vault_path, &path)
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::delete_notebook(&vault_path, &path)
}
// ── Notes ──
@@ -248,6 +265,14 @@ pub fn save_note(
drop(config);
operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(())
}
@@ -272,10 +297,10 @@ pub fn create_note(
}
#[tauri::command]
pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String> {
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path)?;
let entry = operations::create_daily_note(vault_path, date.as_deref())?;
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
@@ -287,8 +312,11 @@ pub fn create_daily_note(state: State<'_, AppState>) -> Result<NoteEntry, String
}
#[tauri::command]
pub fn rename_note(path: String, new_title: String) -> Result<String, String> {
operations::rename_note(&path, &new_title)
pub fn rename_note(state: State<'_, AppState>, path: String, new_title: String) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?.clone();
drop(config);
operations::rename_note(&path, &new_title, &vault_path)
}
#[tauri::command]
@@ -359,10 +387,14 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths, which leaked .helixnotes/history snapshots in as dupes).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
@@ -370,9 +402,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
continue;
}
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
@@ -410,21 +439,28 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
// Pass 1: collect all notes with titles (fast title extraction, no YAML parsing)
// Pass 1: collect ALL notes as nodes (no title deduplication - every note must appear)
let mut graph_nodes = Vec::new();
let mut title_to_idx: HashMap<String, usize> = HashMap::new();
// title → list of node indices (one title can map to multiple notes in different folders)
let mut title_to_idxs: HashMap<String, Vec<usize>> = HashMap::new();
// vault-relative path without extension → idx (for [[subfolder/note]] style links)
let mut relpath_to_idx: HashMap<String, usize> = HashMap::new();
// absolute path → idx (for fast active-note lookup)
let mut path_to_idx: HashMap<String, usize> = HashMap::new();
let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new();
let hn_dir = operations::helixnotes_dir(vault_path);
for entry in walkdir::WalkDir::new(vault)
.into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths; is_hidden also covers .stversions/.stfolder/.trash/.git).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
@@ -442,12 +478,19 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
path.file_stem().unwrap_or_default().to_string_lossy().to_string()
});
// Deduplicate by title — skip if we already have a node with this title
let title_lower = title.to_lowercase();
if title_to_idx.contains_key(&title_lower) { continue; }
let idx = graph_nodes.len();
title_to_idx.insert(title_lower, idx);
let title_lower = title.to_lowercase();
title_to_idxs.entry(title_lower).or_default().push(idx);
path_to_idx.insert(path_str.clone(), idx);
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
// Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
let rel_lower = rel_no_ext.to_string_lossy().replace('\\', "/").to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
graph_nodes.push(crate::types::GraphNode {
title,
path: path_str,
@@ -456,8 +499,22 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
}
// Pass 2: extract edges from wiki-links (inline scan, no regex)
let mut edges = Vec::new();
let mut edge_set: HashSet<(usize, usize)> = HashSet::new();
// edge_map: (src, tgt) → index in edges vec, used to detect reverse links
let mut edges: Vec<crate::types::GraphEdge> = Vec::new();
let mut edge_map: HashMap<(usize, usize), usize> = HashMap::new();
let add_edge = |edges: &mut Vec<crate::types::GraphEdge>, edge_map: &mut HashMap<(usize, usize), usize>, src: usize, tgt: usize| {
if src == tgt { return; }
if edge_map.contains_key(&(src, tgt)) { return; } // exact duplicate
if let Some(&rev_idx) = edge_map.get(&(tgt, src)) {
// Reverse direction already exists - mark it as bidirectional
edges[rev_idx].bidirectional = true;
} else {
let idx = edges.len();
edge_map.insert((src, tgt), idx);
edges.push(crate::types::GraphEdge { source: src, target: tgt, bidirectional: false });
}
};
for (source_idx, body) in contents.iter().enumerate() {
let bytes = body.as_bytes();
@@ -478,11 +535,21 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let link = link.split('^').next().unwrap_or(link);
let link = link.trim().to_lowercase();
if let Some(&target_idx) = title_to_idx.get(&link) {
if target_idx != source_idx {
let key = if source_idx < target_idx { (source_idx, target_idx) } else { (target_idx, source_idx) };
if edge_set.insert(key) {
edges.push(crate::types::GraphEdge { source: source_idx, target: target_idx });
// 1. Try vault-relative path match (e.g. "arkhost/note name")
if let Some(&target_idx) = relpath_to_idx.get(&link) {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
} else if let Some(targets) = title_to_idxs.get(&link) {
// 2. Title match - connect to all notes with this title
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
} else if link.contains('/') {
// 3. Path-based ref: try last segment as title fallback
if let Some(seg) = link.rsplit('/').next() {
if let Some(targets) = title_to_idxs.get(seg) {
for &target_idx in targets {
add_edge(&mut edges, &mut edge_map, source_idx, target_idx);
}
}
}
}
@@ -522,6 +589,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
None
}
// ── Tasks ──
#[tauri::command]
pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let task_re = regex::Regex::new(r"^\s*[-*]\s\[([ xX])\]\s+(.+?)\s*$").unwrap();
let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap();
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap();
let mut tasks = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => continue,
};
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let note_path = path.to_string_lossy().to_string();
for (i, line) in body.lines().enumerate() {
let caps = match task_re.captures(line) {
Some(c) => c,
None => continue,
};
let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open
let content = caps[2].to_string();
let due = due_re.captures(&content).map(|c| c[1].to_string());
let priority = prio_re.captures(&content).map(|c| {
let p = c[1].to_lowercase();
if p == "medium" { "med".to_string() } else { p }
});
// Strip metadata tokens for the display text, then collapse whitespace.
let mut text = due_re.replace_all(&content, "").to_string();
text = prio_re.replace_all(&text, " ").to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
tasks.push(crate::types::TaskItem {
note_path: note_path.clone(),
note_title: meta.title.clone(),
line: i,
raw_line: line.to_string(),
text,
completed,
due,
priority,
});
}
}
Ok(tasks)
}
fn toggle_checkbox_line(line: &str, done: bool) -> String {
let mut s = line.to_string();
if done {
if let Some(pos) = s.find("[ ]") {
s.replace_range(pos..pos + 3, "[x]");
}
} else {
for marker in ["[x]", "[X]"] {
if let Some(pos) = s.find(marker) {
s.replace_range(pos..pos + 3, "[ ]");
break;
}
}
}
s
}
#[tauri::command]
pub fn set_task_done(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
done: bool,
) -> Result<(), String> {
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
// Verify the expected line; if the note drifted, fall back to the first exact match.
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let toggled = toggle_checkbox_line(&lines[idx], done);
if toggled == lines[idx] {
return Ok(()); // already in the desired state
}
lines[idx] = toggled;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_priority_on_line(line: &str, priority: Option<&str>) -> String {
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(?:high|medium|med|low)\b").unwrap();
let stripped = prio_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match priority {
Some(p) => format!("{} !{}", stripped, p),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_priority(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
priority: Option<String>,
) -> Result<(), String> {
// Normalize/validate priority ("medium" -> "med"); None clears it.
let prio = match priority.as_deref() {
None | Some("") | Some("none") => None,
Some("high") => Some("high"),
Some("med") | Some("medium") => Some("med"),
Some("low") => Some("low"),
Some(_) => return Err("Invalid priority".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_priority_on_line(&lines[idx], prio);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_due_on_line(line: &str, due: Option<&str>) -> String {
let due_re = regex::Regex::new(r"(?:^|\s)due:\d{4}-\d{2}-\d{2}\b").unwrap();
let stripped = due_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match due {
Some(d) => format!("{} due:{}", stripped, d),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_due(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
due: Option<String>,
) -> Result<(), String> {
// Validate format (YYYY-MM-DD); None/empty clears the due date.
let date_re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
let due_val: Option<&str> = match due.as_deref() {
None | Some("") => None,
Some(d) if date_re.is_match(d) => Some(d),
Some(_) => return Err("Invalid due date".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_due_on_line(&lines[idx], due_val);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
// ── Search ──
#[tauri::command]
@@ -551,10 +866,10 @@ pub fn reindex(state: State<'_, AppState>) -> Result<(), String> {
// ── Trash ──
#[tauri::command]
pub fn get_trash(state: State<'_, AppState>) -> Result<Vec<NoteEntry>, String> {
pub fn get_trash(state: State<'_, AppState>) -> Result<TrashContents, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::get_trash_notes(vault_path)
operations::get_trash_contents(vault_path)
}
#[tauri::command]
@@ -563,14 +878,29 @@ pub fn restore_note(
trash_path: String,
dest_notebook: Option<String>,
) -> Result<String, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::restore_note(vault_path, &trash_path, dest_notebook.as_deref())
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_note(&vault_path, &trash_path, dest_notebook.as_deref())
}
#[tauri::command]
pub fn permanent_delete(path: String) -> Result<(), String> {
operations::permanent_delete(&path)
pub fn restore_notebook(state: State<'_, AppState>, trash_path: String) -> Result<String, String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::restore_notebook(&vault_path, &trash_path)
}
#[tauri::command]
pub fn permanent_delete(state: State<'_, AppState>, path: String) -> Result<(), String> {
let vault_path = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.as_ref().ok_or("No active vault")?.clone()
};
operations::permanent_delete(&vault_path, &path)
}
#[tauri::command]
@@ -600,7 +930,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard =
@@ -625,14 +955,14 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Ok(buf)
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string())
}
/// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
@@ -652,12 +982,38 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
/// Copy PNG bytes directly to the system clipboard.
#[cfg(desktop)]
#[tauri::command]
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data)
.map_err(|e| format!("Failed to decode image: {}", e))?;
let rgba = img.to_rgba8();
let (w, h) = rgba.dimensions();
let img_data = arboard::ImageData {
width: w as usize,
height: h as usize,
bytes: std::borrow::Cow::Owned(rgba.into_raw()),
};
let mut clipboard = arboard::Clipboard::new()
.map_err(|e| format!("Clipboard init failed: {}", e))?;
clipboard.set_image(img_data)
.map_err(|e| format!("Failed to set clipboard image: {}", e))?;
Ok(())
}
#[cfg(mobile)]
#[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
// ── Attachments ──
#[tauri::command]
@@ -711,6 +1067,7 @@ pub fn set_general_settings(
state: State<'_, AppState>,
compact_notes: bool,
time_format: String,
week_start: String,
gpu_acceleration: bool,
autostart: bool,
pdf_preview: bool,
@@ -722,10 +1079,15 @@ pub fn set_general_settings(
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
restore_last_session: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session;
config.time_format = time_format;
config.week_start = week_start;
config.gpu_acceleration = gpu_acceleration;
config.autostart = autostart;
config.pdf_preview = pdf_preview;
@@ -1335,40 +1697,67 @@ fn resolve_wiki_ref(
reference.to_string()
}
// ── Open file with system default handler ──
// ── Open file/URL with system default handler ──
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
fn xdg_open(arg: &str) -> Result<(), String> {
#[cfg(target_os = "linux")]
{
std::process::Command::new("xdg-open")
.arg(&path)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
let mut cmd = std::process::Command::new("xdg-open");
cmd.arg(arg);
// Clear AppImage environment so child processes find host binaries
// (e.g. gio-launch-desktop on GNOME)
if std::env::var("APPIMAGE").is_ok() {
cmd.env_remove("LD_LIBRARY_PATH")
.env_remove("LD_PRELOAD")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE")
.env_remove("GIO_LAUNCHED_DESKTOP_FILE_PID");
if let Ok(original_path) = std::env::var("PATH_ORIG") {
cmd.env("PATH", original_path);
}
}
cmd.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
#[cfg(target_os = "macos")]
{
std::process::Command::new("open")
.arg(&path)
.arg(arg)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
#[cfg(target_os = "windows")]
{
std::process::Command::new("cmd")
.args(["/C", "start", "", &path])
.args(["/C", "start", "", arg])
.spawn()
.map_err(|e| format!("Failed to open {}: {}", path, e))?;
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
Ok(())
}
#[tauri::command]
pub fn open_file(path: String) -> Result<(), String> {
xdg_open(&path)
}
#[tauri::command]
pub fn open_url(url: String) -> Result<(), String> {
xdg_open(&url)
}
#[tauri::command]
pub fn copy_file_to(source: String, destination: String) -> Result<(), String> {
std::fs::copy(&source, &destination).map_err(|e| format!("Failed to copy file: {}", e))?;
Ok(())
}
#[tauri::command]
pub fn write_bytes_to(destination: String, data: Vec<u8>) -> Result<(), String> {
std::fs::write(&destination, &data).map_err(|e| format!("Failed to write file: {}", e))?;
Ok(())
}
// ── Backup ──
#[tauri::command]
@@ -1531,12 +1920,22 @@ pub fn set_ai_settings(
model: String,
writing_style: Option<String>,
base_url: Option<String>,
ollama_api_key: Option<String>,
openai_compatible_base_url: Option<String>,
openai_compatible_api_key: Option<String>,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
let key = api_key.filter(|k| !k.is_empty());
match provider.as_deref() {
Some("openai") => config.openai_api_key = key,
Some("ollama") => config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()),
Some("ollama") => {
config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty());
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
}
Some("openai_compatible") => {
config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty());
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
}
_ => config.ai_api_key = key,
}
config.ai_provider = provider;
@@ -1555,17 +1954,18 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
.ai_provider
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" {
// Ollama doesn't require an API key
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone()
} else {
config.ai_api_key.clone()
let key = match provider.as_str() {
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
"openai" => config.openai_api_key.clone(),
_ => config.ai_api_key.clone(),
}
.ok_or("No API key configured")?;
let model = config.ai_model.clone();
let base_url = config.ollama_base_url.clone();
let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, base_url)
};
@@ -1596,6 +1996,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
Ok(())
}
// ── Sync (WebDAV) ──
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
if config.sync_provider.as_deref() != Some("webdav") {
return Err("Sync is not configured".to_string());
}
let url = config
.webdav_url
.clone()
.filter(|u| !u.trim().is_empty())
.ok_or("WebDAV URL is not set")?;
Ok(crate::sync::WebdavConfig {
url,
username: config.webdav_username.clone().unwrap_or_default(),
password: config.webdav_password.clone().unwrap_or_default(),
})
}
#[tauri::command]
pub fn set_sync_settings(
state: State<'_, AppState>,
provider: Option<String>,
url: Option<String>,
username: Option<String>,
password: Option<String>,
sync_on_open: bool,
sync_on_change: bool,
sync_interval_minutes: u32,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.sync_provider = provider.filter(|p| !p.is_empty());
config.webdav_url = url.filter(|u| !u.trim().is_empty());
config.webdav_username = username.filter(|u| !u.is_empty());
config.webdav_password = password.filter(|p| !p.is_empty());
config.sync_on_open = sync_on_open;
config.sync_on_change = sync_on_change;
config.sync_interval_minutes = sync_interval_minutes;
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn test_sync_connection(app: AppHandle) -> Result<(), String> {
let cfg = {
let state = app.state::<AppState>();
let config = state.config.lock().map_err(|e| e.to_string())?;
sync_config_from(&config)?
};
std::thread::spawn(move || {
use tauri::Emitter;
match crate::sync::test_connection(cfg) {
Ok(msg) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": true, "message": msg }),
);
}
Err(e) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": false, "error": e }),
);
}
}
});
Ok(())
}
#[tauri::command]
pub fn sync_now(app: AppHandle) -> Result<(), String> {
use std::sync::atomic::Ordering;
// Guard against overlapping syncs (manual button + interval + on-change can collide).
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
return Ok(()); // a sync is already running
}
let (vault, cfg) = {
let state = app.state::<AppState>();
let config = match state.config.lock() {
Ok(c) => c,
Err(e) => {
state.syncing.store(false, Ordering::SeqCst);
return Err(e.to_string());
}
};
let gathered = config
.active_vault
.clone()
.ok_or_else(|| "No active vault".to_string())
.and_then(|v| sync_config_from(&config).map(|c| (v, c)));
match gathered {
Ok(vc) => vc,
Err(e) => {
drop(config);
state.syncing.store(false, Ordering::SeqCst);
return Err(e);
}
}
};
std::thread::spawn(move || {
use tauri::Emitter;
let result = crate::sync::run_sync(app.clone(), vault, cfg);
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
match result {
Ok(summary) => {
let ts = chrono::Utc::now().to_rfc3339();
if let Ok(mut config) = app.state::<AppState>().config.lock() {
config.last_sync_time = Some(ts.clone());
let _ = save_app_config(&config);
}
let _ = app.emit(
"sync-done",
serde_json::json!({ "success": true, "summary": summary, "last_sync_time": ts }),
);
}
Err(e) => {
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
}
}
});
Ok(())
}
#[tauri::command]
pub fn ai_ask(
app: AppHandle,
@@ -1611,26 +2133,28 @@ pub fn ai_ask(
.ai_provider
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" {
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone()
} else {
config.ai_api_key.clone()
let key = match provider.as_str() {
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
"openai" => config.openai_api_key.clone(),
_ => config.ai_api_key.clone(),
}
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
let model = config.ai_model.clone();
let style = config.ai_writing_style.clone();
let base_url = config.ollama_base_url.clone();
let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, style, base_url)
};
let mut system_prompt = "You are a helpful writing assistant inside a note-taking app called HelixNotes. \
You help users improve, rewrite, summarize, and transform their text. \
Return ONLY the resulting text no explanations, no markdown code fences, no preamble. \
Return ONLY the resulting text - no explanations, no markdown code fences, no preamble. \
Preserve the original language of the text unless specifically asked to translate. \
Preserve any markdown formatting (bold, italic, links, images, tables, etc.) unless the user asks to change it. \
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions they represent images and embedded files.".to_string();
If the text contains placeholders like __MEDIA_0__, __MEDIA_1__, etc., keep them exactly as they are in their original positions - they represent images and embedded files.".to_string();
if let Some(ref style) = writing_style {
system_prompt.push_str(&format!(
@@ -1675,17 +2199,21 @@ pub fn ai_ask(
// ── Helpers ──
static ANDROID_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
// On mobile (Android + iOS) the OS config dir is injected at startup via the Tauri
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub fn set_android_config_dir(path: std::path::PathBuf) {
let _ = ANDROID_CONFIG_DIR.set(path);
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
let _ = MOBILE_CONFIG_DIR.set(path);
}
fn app_config_path() -> Result<std::path::PathBuf, String> {
let app_dir = if let Some(config_dir) = dirs::config_dir() {
// Prefer the injected mobile dir when present (set only on mobile); fall back to
// the platform config dir on desktop.
let app_dir = if let Some(mobile_dir) = MOBILE_CONFIG_DIR.get() {
mobile_dir.join("helixnotes")
} else if let Some(config_dir) = dirs::config_dir() {
config_dir.join("helixnotes")
} else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() {
android_dir.join("helixnotes")
} else {
return Err("Config directory not available yet".to_string());
};
+1 -1
View File
@@ -157,7 +157,7 @@ fn prune_versions(dir: &Path, max: u32) -> Result<(), String> {
.filter(|p| p.extension().map_or(false, |ext| ext == "md"))
.collect();
// Sort by name (timestamps sort lexicographically) newest last
// Sort by name (timestamps sort lexicographically) - newest last
files.sort();
if files.len() as u32 > max {
+126 -13
View File
@@ -4,6 +4,7 @@ mod commands;
mod history;
mod search;
mod state;
mod sync;
mod types;
mod vault;
@@ -11,7 +12,7 @@ use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
@@ -20,14 +21,20 @@ use tauri::{
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
#[cfg(target_os = "linux")]
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let config = commands::load_app_config();
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let show_tray = config.show_tray_icon;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config);
@@ -44,28 +51,28 @@ pub fn run() {
)?;
}
// On Android, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")]
// On mobile, set config dir from Tauri's path resolver, then reload config
#[cfg(mobile)]
{
if let Ok(config_dir) = app.path().config_dir() {
commands::set_android_config_dir(config_dir);
commands::set_mobile_config_dir(config_dir);
} else if let Ok(data_dir) = app.path().data_dir() {
commands::set_android_config_dir(data_dir);
commands::set_mobile_config_dir(data_dir);
}
// Reload config now that the Android config dir is available
// Reload config now that the mobile config dir is available
let reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded;
});
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
if show_tray {
setup_tray(app)?;
}
// Check CLI args for a .md file path on initial launch
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
let file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim();
@@ -97,7 +104,9 @@ pub fn run() {
commands::set_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
commands::rename_notebook,
commands::delete_notebook,
@@ -113,10 +122,15 @@ pub fn run() {
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
commands::set_task_priority,
commands::set_task_due,
commands::search_notes,
commands::reindex,
commands::get_trash,
commands::restore_note,
commands::restore_notebook,
commands::permanent_delete,
commands::empty_trash,
commands::load_vault_state,
@@ -135,7 +149,10 @@ pub fn run() {
commands::get_vault_stats,
commands::import_obsidian,
commands::open_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
commands::copy_png_to_clipboard,
commands::create_backup,
commands::list_backups,
commands::restore_backup,
@@ -147,11 +164,87 @@ pub fn run() {
commands::set_ai_settings,
commands::test_ai_connection,
commands::ai_ask,
commands::set_sync_settings,
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type,
commands::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);
#[cfg(not(target_os = "android"))]
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window
@@ -212,7 +305,7 @@ pub fn run() {
.expect("error while running tauri application");
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
@@ -251,3 +344,23 @@ fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
Ok(())
}
fn percent_decode(input: &str) -> String {
let mut output = Vec::with_capacity(input.len());
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' && i + 2 < bytes.len() {
if let Ok(decoded) =
u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
{
output.push(decoded);
i += 3;
continue;
}
}
output.push(bytes[i]);
i += 1;
}
String::from_utf8_lossy(&output).to_string()
}
+255 -21
View File
@@ -5,15 +5,163 @@ use std::fs;
use std::path::Path;
use std::sync::Mutex;
use tantivy::collector::TopDocs;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")]
#[cfg(mobile)]
use tantivy::directory::RamDirectory;
use tantivy::query::QueryParser;
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
use tantivy::schema::*;
use tantivy::{Index, IndexWriter, TantivyDocument};
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir;
/// Bumped whenever the index schema or tokenizer changes, so the on-disk index is
/// wiped and rebuilt once on the next vault open (the index is derived from the
/// notes, so this never loses data).
const INDEX_SCHEMA_VERSION: &str = "2-cjk-bigram";
/// Per-vault search index dir in local app-data, kept out of the (possibly synced) vault.
#[cfg(desktop)]
fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes());
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
}
/// True for characters in the CJK / Japanese / Korean blocks, which are written
/// without spaces between words. These get uni/bi-gram tokenized so substring
/// search works; everything else keeps the default word-splitting behaviour.
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x3400..=0x4DBF // CJK Unified Ideographs Extension A
| 0x4E00..=0x9FFF // CJK Unified Ideographs (Han)
| 0x3040..=0x309F // Hiragana
| 0x30A0..=0x30FF // Katakana
| 0xAC00..=0xD7AF // Hangul Syllables
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
| 0xFF00..=0xFFEF // Halfwidth and Fullwidth Forms
)
}
/// Tokenize `text` so that CJK runs become overlapping unigrams + bigrams (recall
/// over precision: a query for any substring of a CJK run will match), while runs
/// of other alphanumeric characters become a single word token (same as tantivy's
/// SimpleTokenizer, so Latin/English indexing is unchanged). Lowercasing is applied
/// by a LowerCaser filter in the analyzer, not here.
fn cjk_tokens(text: &str) -> Vec<Token> {
let mut tokens: Vec<Token> = Vec::new();
let mut position: usize = 0;
let mut cjk_run: Vec<(usize, char)> = Vec::new();
let mut word = String::new();
let mut word_start: usize = 0;
macro_rules! flush_word {
() => {{
if !word.is_empty() {
let len = word.len();
tokens.push(Token {
offset_from: word_start,
offset_to: word_start + len,
position,
text: std::mem::take(&mut word),
position_length: 1,
});
position += 1;
}
}};
}
macro_rules! flush_cjk {
() => {{
let n = cjk_run.len();
for i in 0..n {
let (off, ch) = cjk_run[i];
tokens.push(Token {
offset_from: off,
offset_to: off + ch.len_utf8(),
position,
text: ch.to_string(),
position_length: 1,
});
position += 1;
}
for i in 0..n.saturating_sub(1) {
let (off1, c1) = cjk_run[i];
let (off2, c2) = cjk_run[i + 1];
let mut s = String::with_capacity(c1.len_utf8() + c2.len_utf8());
s.push(c1);
s.push(c2);
tokens.push(Token {
offset_from: off1,
offset_to: off2 + c2.len_utf8(),
position,
text: s,
position_length: 1,
});
position += 1;
}
cjk_run.clear();
}};
}
for (offset, c) in text.char_indices() {
if is_cjk(c) {
flush_word!();
cjk_run.push((offset, c));
} else if c.is_alphanumeric() {
flush_cjk!();
if word.is_empty() {
word_start = offset;
}
word.push(c);
} else {
flush_cjk!();
flush_word!();
}
}
flush_cjk!();
flush_word!();
let _ = position; // the final flush increments position but nothing reads it after
tokens
}
/// A pre-computed token stream (all tokens collected up front by `cjk_tokens`).
struct PreTokenizedStream {
tokens: Vec<Token>,
idx: usize,
}
impl TokenStream for PreTokenizedStream {
fn advance(&mut self) -> bool {
if self.idx < self.tokens.len() {
self.idx += 1;
true
} else {
false
}
}
fn token(&self) -> &Token {
&self.tokens[self.idx - 1]
}
fn token_mut(&mut self) -> &mut Token {
&mut self.tokens[self.idx - 1]
}
}
#[derive(Clone)]
struct CjkTokenizer;
impl Tokenizer for CjkTokenizer {
type TokenStream<'a> = PreTokenizedStream;
fn token_stream<'a>(&'a mut self, text: &'a str) -> PreTokenizedStream {
PreTokenizedStream {
tokens: cjk_tokens(text),
idx: 0,
}
}
}
pub struct SearchIndex {
index: Index,
writer: Mutex<Option<IndexWriter>>,
@@ -29,29 +177,70 @@ impl SearchIndex {
pub fn new(vault_path: &str) -> Result<Self, String> {
let mut schema_builder = Schema::builder();
let path_field = schema_builder.add_text_field("path", STRING | STORED);
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
let body_field = schema_builder.add_text_field("body", TEXT);
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
// CJK-aware tokenizer for the indexed text fields (keeps Latin/English
// behaviour identical; adds substring matching for Chinese/Japanese/Korean).
let cjk_indexing = TextFieldIndexing::default()
.set_tokenizer("cjk")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let cjk_text = TextOptions::default().set_indexing_options(cjk_indexing.clone());
let cjk_text_stored = cjk_text.clone().set_stored();
let title_field = schema_builder.add_text_field("title", cjk_text_stored.clone());
let body_field = schema_builder.add_text_field("body", cjk_text);
let tags_field = schema_builder.add_text_field("tags", cjk_text_stored);
let schema = schema_builder.build();
// Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem)
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
// Desktop: use mmap directory for persistent index on disk
#[cfg(target_os = "android")]
#[cfg(mobile)]
let index = {
let dir = RamDirectory::create();
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
};
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let index = {
let index_dir = helixnotes_dir(vault_path).join("search_index");
let base = vault_index_base(vault_path)
.unwrap_or_else(|| helixnotes_dir(vault_path).join("search_index"));
let index_dir = base.join("index");
let version_path = base.join("version");
// Remove the old in-vault index so it stops syncing.
let hn = helixnotes_dir(vault_path);
let _ = fs::remove_dir_all(hn.join("search_index"));
let _ = fs::remove_file(hn.join("search_index.version"));
// One-time wipe when the schema/tokenizer version changes; rebuild() repopulates.
let version_ok = fs::read_to_string(&version_path)
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
.unwrap_or(false);
if !version_ok {
let _ = fs::remove_dir_all(&index_dir);
}
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
let idx = match Index::open_or_create(dir, schema.clone()) {
Ok(idx) => idx,
Err(_) => {
// Schema mismatch (older index) or corruption: wipe and recreate.
let _ = fs::remove_dir_all(&index_dir);
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
}
};
let _ = fs::write(&version_path, INDEX_SCHEMA_VERSION);
idx
};
#[cfg(target_os = "android")]
// Register the CJK-aware tokenizer (in-memory; must be done on every open,
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
);
#[cfg(mobile)]
let heap_size = 15_000_000;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
@@ -152,14 +341,59 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher();
let query_parser = QueryParser::for_index(
&self.index,
vec![self.title_field, self.body_field, self.tags_field],
);
let fields = vec![self.title_field, self.body_field, self.tags_field];
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
let mut analyzer = self
.index
.tokenizers()
.get("cjk")
.ok_or("cjk tokenizer not registered")?;
let terms: Vec<String> = {
let mut out = Vec::new();
let mut stream = analyzer.token_stream(query_str);
while stream.advance() {
out.push(stream.token().text.clone());
}
out
};
let query = query_parser
.parse_query(query_str)
.map_err(|e| e.to_string())?;
// For each term, OR queries across all fields, then AND all terms together.
// CJK tokens (uni/bigrams) already encode segmentation -> exact term match.
// Latin tokens keep prefix + fuzzy matching (unchanged English behaviour).
let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let is_cjk_term = term.chars().any(is_cjk);
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
if is_cjk_term {
let exact: Box<dyn Query> = Box::new(TermQuery::new(
Term::from_field_text(field, term),
IndexRecordOption::WithFreqs,
));
vec![(Occur::Should, exact)]
} else {
let prefix: Box<dyn Query> = Box::new(PhrasePrefixQuery::new(
vec![Term::from_field_text(field, term)],
));
let fuzzy: Box<dyn Query> = Box::new(FuzzyTermQuery::new(
Term::from_field_text(field, term),
1,
true,
));
vec![(Occur::Should, prefix), (Occur::Should, fuzzy)]
}
})
.collect();
let combined: Box<dyn Query> = Box::new(BooleanQuery::new(field_queries));
(Occur::Must, combined)
})
.collect();
let query = BooleanQuery::new(term_queries);
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit))
+2
View File
@@ -10,6 +10,7 @@ pub struct AppState {
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
}
@@ -20,6 +21,7 @@ impl AppState {
search_index: Mutex::new(None),
watcher: Mutex::new(None),
importing: AtomicBool::new(false),
syncing: AtomicBool::new(false),
pending_open_file: Mutex::new(None),
}
}
+670
View File
@@ -0,0 +1,670 @@
// WebDAV sync provider.
//
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
// records what was in sync last time, so we can do a three-way diff (local vs remote
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{Emitter, Manager};
use walkdir::WalkDir;
// ───────────────────────── config ─────────────────────────
pub struct WebdavConfig {
pub url: String,
pub username: String,
pub password: String,
}
#[derive(Default, Clone, Serialize)]
pub struct SyncSummary {
pub uploaded: u32,
pub downloaded: u32,
pub deleted_local: u32,
pub deleted_remote: u32,
pub conflicts: u32,
}
// ───────────────────────── manifest ─────────────────────────
#[derive(Default, Serialize, Deserialize)]
struct SyncManifest {
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
}
#[derive(Clone, Serialize, Deserialize)]
struct ManifestEntry {
local_hash: String, // sha256 of local content at last sync
remote_etag: String, // remote etag at last sync
}
fn manifest_path(vault: &str) -> PathBuf {
helixnotes_dir(vault).join("sync_state.json")
}
fn load_manifest(vault: &str) -> SyncManifest {
fs::read_to_string(manifest_path(vault))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_manifest(vault: &str, m: &SyncManifest) {
if let Ok(s) = serde_json::to_string(m) {
let _ = fs::write(manifest_path(vault), s);
}
}
// ───────────────────────── helpers ─────────────────────────
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
let mut s = String::with_capacity(64);
for b in digest {
s.push_str(&format!("{:02x}", b));
}
s
}
fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
}
/// Percent-encode each path segment, keeping the `/` separators.
fn encode_path(relpath: &str) -> String {
relpath
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/")
}
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
let (dir, name) = match relpath.rfind('/') {
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
None => ("", relpath),
};
let (stem, ext) = match name.rfind('.') {
Some(i) => (&name[..i], &name[i..]),
None => (name, ""),
};
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
}
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
let target = vault.join(relpath);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&target, bytes).map_err(|e| e.to_string())
}
struct LocalFile {
hash: String,
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
} else {
rel.ends_with(".md")
}
}
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
let vault_path = Path::new(vault);
let mut map = HashMap::new();
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if !p.is_file() {
continue;
}
let rel = match p.strip_prefix(vault_path) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => continue,
};
if !is_synced_relpath(&rel) {
continue;
}
let bytes = match fs::read(p) {
Ok(b) => b,
Err(_) => continue,
};
map.insert(
rel,
LocalFile {
hash: sha256_hex(&bytes),
path: p.to_path_buf(),
},
);
}
Ok(map)
}
// ───────────────────────── WebDAV client ─────────────────────────
struct RemoteEntry {
relpath: String,
etag: String,
is_dir: bool,
}
struct WebdavClient {
base: String, // normalized, no trailing slash
base_path: String, // path component of base, no trailing slash (percent-encoded)
client: reqwest::blocking::Client,
user: String,
pass: String,
}
impl WebdavClient {
fn new(cfg: WebdavConfig) -> Result<Self, String> {
let base = cfg.url.trim().trim_end_matches('/').to_string();
if !base.starts_with("http://") && !base.starts_with("https://") {
return Err("WebDAV URL must start with http:// or https://".to_string());
}
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
let base_path = base_url.path().trim_end_matches('/').to_string();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
Ok(Self {
base,
base_path,
client,
user: cfg.username,
pass: cfg.password,
})
}
fn url_for(&self, relpath: &str) -> String {
if relpath.is_empty() {
self.base.clone()
} else {
format!("{}/{}", self.base, encode_path(relpath))
}
}
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.header("Depth", depth)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.map_err(|e| e.to_string())?;
let status = resp.status();
if status.as_u16() == 401 {
return Err("Authentication failed (check username / app password)".to_string());
}
if status.as_u16() == 404 {
return Err("WebDAV path not found (check the URL)".to_string());
}
if !status.is_success() {
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
}
let text = resp.text().map_err(|e| e.to_string())?;
parse_multistatus(&text, &self.base_path)
}
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
/// (Depth: infinity is disabled on some servers, so we walk instead).
fn list(&self) -> Result<HashMap<String, String>, String> {
let mut files = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = vec![String::new()];
while let Some(dir) = queue.pop() {
if !visited.insert(dir.clone()) {
continue;
}
for e in self.propfind(&dir, "1")? {
if e.relpath == dir {
continue; // the collection itself
}
if e.is_dir {
if !visited.contains(&e.relpath) {
queue.push(e.relpath);
}
} else {
files.insert(e.relpath, e.etag);
}
}
}
Ok(files)
}
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
let resp = self
.client
.get(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
}
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
let resp = self
.client
.put(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.body(bytes.to_vec())
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp
.headers()
.get("etag")
.and_then(|h| h.to_str().ok())
.map(normalize_etag))
}
fn delete(&self, relpath: &str) -> Result<(), String> {
let resp = self
.client
.delete(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status();
if s.is_success() || s.as_u16() == 404 {
Ok(())
} else {
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
}
}
fn mkcol(&self, relpath: &str) -> Result<(), String> {
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status().as_u16();
// 201 created; 405 already exists; 301 redirect-to-existing.
if resp.status().is_success() || s == 405 || s == 301 {
Ok(())
} else {
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
}
}
/// Ensure every ancestor collection of `relpath` exists on the remote.
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
let parts: Vec<&str> = relpath.split('/').collect();
if parts.len() <= 1 {
return Ok(());
}
let mut cur = String::new();
for part in &parts[..parts.len() - 1] {
if !cur.is_empty() {
cur.push('/');
}
cur.push_str(part);
if created.insert(cur.clone()) {
self.mkcol(&cur)?;
}
}
Ok(())
}
fn put_with_dirs(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
/// the base collection itself.
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
let path = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href).ok()?.path().to_string()
} else {
href.to_string()
};
let path = path.trim_end_matches('/');
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
if rest.is_empty() {
return None;
}
Some(urlencoding::decode(rest).ok()?.into_owned())
}
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
#[derive(PartialEq)]
enum Cap {
None,
Href,
Etag,
}
let mut reader = Reader::from_str(xml);
let mut entries = Vec::new();
let mut href: Option<String> = None;
let mut etag: Option<String> = None;
let mut is_dir = false;
let mut cap = Cap::None;
let mut buf = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => match e.local_name().as_ref() {
b"response" => {
href = None;
etag = None;
is_dir = false;
}
b"href" => {
cap = Cap::Href;
buf.clear();
}
b"getetag" => {
cap = Cap::Etag;
buf.clear();
}
b"collection" => is_dir = true,
_ => {}
},
Ok(Event::Empty(e)) => {
if e.local_name().as_ref() == b"collection" {
is_dir = true;
}
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
}
}
}
Ok(Event::End(e)) => match e.local_name().as_ref() {
b"href" => {
href = Some(buf.clone());
cap = Cap::None;
}
b"getetag" => {
etag = Some(buf.clone());
cap = Cap::None;
}
b"response" => {
if let Some(h) = href.take() {
if let Some(rel) = href_to_relpath(&h, base_path) {
entries.push(RemoteEntry {
relpath: rel,
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
is_dir,
});
}
}
}
_ => {}
},
Ok(Event::Eof) => break,
Err(e) => return Err(format!("XML parse error: {e}")),
_ => {}
}
}
Ok(entries)
}
// ───────────────────────── sync engine ─────────────────────────
fn apply_changes(
client: &WebdavClient,
vault: &str,
local: &HashMap<String, LocalFile>,
remote: &HashMap<String, String>,
manifest: &SyncManifest,
) -> Result<(SyncManifest, SyncSummary), String> {
let vault_path = Path::new(vault);
let mut new_m = SyncManifest::default();
let mut sum = SyncSummary::default();
let mut created: HashSet<String> = HashSet::new();
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
let mut keys: HashSet<&String> = HashSet::new();
keys.extend(local.keys());
keys.extend(remote.keys());
keys.extend(manifest.files.keys());
for key in keys {
let l = local.get(key);
let r = remote.get(key);
let m = manifest.files.get(key);
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
if !local_changed && !remote_changed {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
},
);
sum.uploaded += 1;
} else if !local_changed && remote_changed {
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// both changed: compare contents; identical => no real conflict.
let rbytes = client.get(key)?;
let rhash = sha256_hex(&rbytes);
if rhash == lf.hash {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else {
// keep-both: remote becomes canonical; local saved as a conflict copy.
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
write_local(vault_path, key, &rbytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: rhash,
remote_etag: re.clone(),
},
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
},
);
sum.conflicts += 1;
}
}
}
(Some(lf), None) => match m {
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
}
Some(me) => {
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
} else {
// remote deleted, local unchanged -> delete local
let _ = fs::remove_file(&lf.path);
sum.deleted_local += 1;
}
}
},
(None, Some(re)) => match m {
None => {
// new remote -> download
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
}
Some(me) => {
if &me.remote_etag != re {
// local deleted but remote changed -> resurrect (download)
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// local deleted, remote unchanged -> delete remote
client.delete(key)?;
sum.deleted_remote += 1;
}
}
},
(None, None) => {
// gone on both sides -> drop from manifest
}
}
}
Ok((new_m, sum))
}
/// Validate the connection (auth + URL) with a cheap PROPFIND.
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
let client = WebdavClient::new(cfg)?;
client.propfind("", "0")?;
Ok("Connection successful".to_string())
}
/// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering;
let state = app.state::<AppState>();
let client = WebdavClient::new(cfg)?;
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
let remote: HashMap<String, String> = client
.list()?
.into_iter()
.filter(|(k, _)| is_synced_relpath(k))
.collect();
let local = scan_local(&vault)?;
let manifest = load_manifest(&vault);
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
state.importing.store(true, Ordering::Relaxed);
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
state.importing.store(false, Ordering::Relaxed);
let (new_manifest, summary) = applied?;
save_manifest(&vault, &new_manifest);
// Re-index after files changed (search is non-critical; ignore failures).
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.rebuild(&vault);
}
}
// The watcher was muted during apply, so nudge the UI to refresh its lists.
let _ = app.emit(
"file-changed",
crate::types::FileEvent {
event_type: "modify".to_string(),
path: vault.clone(),
},
);
Ok(summary)
}
+98
View File
@@ -19,6 +19,20 @@ pub struct NoteEntry {
pub preview: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashContents {
pub notes: Vec<NoteEntry>,
pub notebooks: Vec<TrashNotebookEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TrashNotebookEntry {
pub name: String,
pub path: String,
pub note_count: usize,
pub modified: DateTime<Utc>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotebookEntry {
pub name: String,
@@ -56,9 +70,15 @@ pub struct AppConfig {
#[serde(default)]
pub line_height: Option<f64>,
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default)]
pub gpu_acceleration: bool,
#[serde(default)]
@@ -95,6 +115,12 @@ pub struct AppConfig {
pub openai_api_key: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub ollama_api_key: Option<String>,
#[serde(default)]
pub openai_compatible_base_url: Option<String>,
#[serde(default)]
pub openai_compatible_api_key: Option<String>,
#[serde(default = "default_ai_model")]
pub ai_model: String,
#[serde(default)]
@@ -107,6 +133,25 @@ pub struct AppConfig {
pub close_to_tray: bool,
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
#[serde(default)]
pub restore_last_session: bool,
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
#[serde(default)]
pub sync_provider: Option<String>,
#[serde(default)]
pub webdav_url: Option<String>,
#[serde(default)]
pub webdav_username: Option<String>,
#[serde(default)]
pub webdav_password: Option<String>,
#[serde(default)]
pub sync_on_open: bool,
#[serde(default)]
pub sync_on_change: bool,
#[serde(default)]
pub sync_interval_minutes: u32,
#[serde(default)]
pub last_sync_time: Option<String>,
}
fn default_true() -> bool {
@@ -129,6 +174,10 @@ fn default_title_mode() -> String {
"input".to_string()
}
fn default_week_start() -> String {
"monday".to_string()
}
fn default_max_versions() -> u32 {
20
}
@@ -147,8 +196,11 @@ impl Default for AppConfig {
font_size: None,
font_family: None,
line_height: None,
ui_scale: None,
compact_notes: false,
show_note_dates: true,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
gpu_acceleration: true,
autostart: false,
pdf_preview: false,
@@ -167,12 +219,24 @@ impl Default for AppConfig {
ai_api_key: None,
openai_api_key: None,
ollama_base_url: None,
ollama_api_key: None,
openai_compatible_base_url: None,
openai_compatible_api_key: None,
ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None,
default_view_mode: false,
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
restore_last_session: false,
sync_provider: None,
webdav_url: None,
webdav_username: None,
webdav_password: None,
sync_on_open: false,
sync_on_change: false,
sync_interval_minutes: 0,
last_sync_time: None,
}
}
}
@@ -185,6 +249,20 @@ pub struct VaultState {
pub sidebar_collapsed: bool,
#[serde(default)]
pub collapsed_notebooks: Vec<String>,
#[serde(default)]
pub notebook_sort_mode: String,
#[serde(default)]
pub notebook_order: std::collections::HashMap<String, i32>,
#[serde(default)]
pub sort_mode: String,
#[serde(default)]
pub last_view_mode: String,
#[serde(default)]
pub last_notebook: Option<String>,
#[serde(default)]
pub last_tag: Option<String>,
#[serde(default)]
pub tasks_layout: String,
}
impl Default for VaultState {
@@ -195,6 +273,13 @@ impl Default for VaultState {
notelist_width: 280.0,
sidebar_collapsed: false,
collapsed_notebooks: Vec::new(),
notebook_sort_mode: String::new(),
notebook_order: std::collections::HashMap::new(),
sort_mode: String::new(),
last_view_mode: String::new(),
last_notebook: None,
last_tag: None,
tasks_layout: String::new(),
}
}
}
@@ -271,4 +356,17 @@ pub struct GraphNode {
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>,
}
+2 -2
View File
@@ -61,7 +61,7 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
.and_then(|s| parse_date_flexible(&s))
.unwrap_or_else(Utc::now);
// Don't generate UUID on read empty string signals "no ID yet"
// Don't generate UUID on read - empty string signals "no ID yet"
let id = fm.id.unwrap_or_default();
let meta = NoteMeta {
@@ -309,7 +309,7 @@ fn strip_html_and_markdown(input: &str) -> String {
continue;
}
// Markdown links: [text](url) keep the text, skip the URL
// Markdown links: [text](url) - keep the text, skip the URL
if ch == '[' {
let mut link_text = String::new();
let mut depth = 1;
+329 -23
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::vault::frontmatter;
use chrono::{Local, Utc};
use chrono::{DateTime, Local, Locale, Utc};
use rayon::prelude::*;
use std::fs;
use std::io::Read;
@@ -63,7 +63,9 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false) && !is_hidden(&e.path())
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
})
.collect();
@@ -152,7 +154,7 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
(entries, note_count)
}
fn is_hidden(path: &Path) -> bool {
pub(crate) fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| {
@@ -165,6 +167,22 @@ fn is_hidden(path: &Path) -> bool {
.unwrap_or(false)
}
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path);
@@ -176,8 +194,8 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
return Err("Path does not exist".to_string());
}
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
#[cfg(target_os = "android")]
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)]
{
let mut notes = Vec::new();
// Log what read_dir sees
@@ -220,12 +238,12 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
}
}
}
log::info!("scan_notes: Android scan found {} notes", notes.len());
log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root)
@@ -261,9 +279,9 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")]
#[cfg(mobile)]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB enough for frontmatter with tags, title, pinned
// Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
@@ -276,7 +294,7 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy()
.to_string();
let (mut meta, _content) = frontmatter::parse_note(&raw, &filename);
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) {
@@ -294,11 +312,13 @@ fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteE
.to_string_lossy()
.to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry {
path: path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview: String::new(),
preview,
})
}
@@ -449,10 +469,66 @@ pub fn create_note(
})
}
pub fn create_daily_note(vault_path: &str) -> Result<NoteEntry, String> {
let today = Local::now();
let date_str = today.format("%Y-%m-%d").to_string();
let title = today.format("%B %d, %Y").to_string();
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
@@ -567,17 +643,26 @@ pub fn delete_notebook(vault_path: &str, notebook_path: &str) -> Result<(), Stri
let trash_name = format!("{}_{}", timestamp, dirname);
let dest = trash_dir.join(&trash_name);
// Save original relative path in a sidecar .meta file for restore
let relative = src
.strip_prefix(vault_path)
.unwrap_or(src)
.to_string_lossy()
.to_string();
let meta_path = trash_dir.join(format!("{}.meta", trash_name));
let _ = fs::write(&meta_path, &relative);
fs::rename(src, dest).map_err(|e| e.to_string())?;
Ok(())
}
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<String, String> {
let src = Path::new(path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
// Update frontmatter
// Read old title before renaming
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src
.file_name()
@@ -585,6 +670,10 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
.to_string_lossy()
.to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
let old_title = meta.title.clone();
let old_path_str = src.to_string_lossy().to_string();
// Update frontmatter
meta.title = new_title.to_string();
meta.modified = Utc::now();
if meta.id.is_empty() {
@@ -601,7 +690,134 @@ pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
fs::rename(src, &new_path).map_err(|e| e.to_string())?;
}
Ok(new_path.to_string_lossy().to_string())
let new_path_str = new_path.to_string_lossy().to_string();
// Update wikilinks in other notes that reference this note
update_wikilinks_after_rename(vault_path, &old_path_str, &new_path_str, &old_title, new_title);
Ok(new_path_str)
}
/// Walk all .md files in the vault and update wikilink references after a note rename.
/// Updates both HTML data-attributes (data-path, data-title) and raw [[old_title]] references.
fn update_wikilinks_after_rename(
vault_path: &str,
old_path: &str,
new_path: &str,
old_title: &str,
new_title: &str,
) {
let vault = Path::new(vault_path);
let hn_dir = helixnotes_dir(vault_path);
// Vault-relative path ref (without .md), normalized to forward slashes so it
// matches [[folder/note]] wikilinks on Windows (OS paths use backslashes there).
let rel_ref = |p: &str| -> String {
let rel = Path::new(p)
.strip_prefix(vault)
.map(|r| r.to_string_lossy().into_owned())
.unwrap_or_else(|_| p.to_string())
.replace('\\', "/");
rel.strip_suffix(".md").unwrap_or(&rel).to_string()
};
let old_rel_ref = rel_ref(old_path);
let new_rel_ref = rel_ref(new_path);
// Check if another note with the same old_title exists in the vault.
// If so, title-based replacements (rules 1-3) are ambiguous and must be skipped,
// because we can't tell which [[Old Title]] ref points to the renamed note
// vs. the other note with the same title.
let title_is_unique = !WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
.any(|e| {
let p = e.path();
if !p.is_file() || p.to_string_lossy().as_ref() == new_path {
return false;
}
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
.map(|s| s.eq_ignore_ascii_case(old_title))
.unwrap_or(false)
});
for entry in WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy();
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let mut result = content.clone();
// Notes are saved as markdown with [[ref]] or [[ref|display]] syntax.
// Update all wikilink forms that reference the renamed note:
// Title-based rules only apply when the old title is unique in the vault.
// If another note shares the same title, these would be ambiguous.
if old_title != new_title && title_is_unique {
// 1. Short title ref: [[Old Title]] → [[New Title]]
result = result.replace(
&format!("[[{}]]", old_title),
&format!("[[{}]]", new_title),
);
// 2. Short title with alias: [[Old Title|display]] → [[New Title|display]]
result = result.replace(
&format!("[[{}|", old_title),
&format!("[[{}|", new_title),
);
// 3. Short title as alias display: [[ref|Old Title]] → [[ref|New Title]]
result = result.replace(
&format!("|{}]]", old_title),
&format!("|{}]]", new_title),
);
}
// Path-based rules are always safe (paths are unique).
if old_rel_ref != new_rel_ref {
// 4. Path-based ref: [[folder/Old Name]] → [[folder/New Name]]
result = result.replace(
&format!("[[{}]]", old_rel_ref),
&format!("[[{}]]", new_rel_ref),
);
// 5. Path-based ref with alias: [[folder/Old Name|display]] → [[folder/New Name|display]]
// Also update the alias if it matches the old title.
// e.g. [[folder/Old Name|Old Title]] → [[folder/New Name|New Title]]
if old_title != new_title {
result = result.replace(
&format!("[[{}|{}]]", old_rel_ref, old_title),
&format!("[[{}|{}]]", new_rel_ref, new_title),
);
}
// For aliases that DON'T match the old title, just update the ref part
result = result.replace(
&format!("[[{}|", old_rel_ref),
&format!("[[{}|", new_rel_ref),
);
}
if result != content {
let _ = fs::write(path, &result);
}
}
}
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
@@ -669,14 +885,29 @@ pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, S
Ok(dest.to_string_lossy().to_string())
}
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
/// Remove a directory inside trash if it's empty (after restoring/deleting its last note).
fn cleanup_empty_trash_dir(vault_path: &str, dir: Option<&Path>) {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if let Some(d) = dir {
if d != trash_dir && d.starts_with(&trash_dir) {
if let Ok(mut entries) = fs::read_dir(d) {
if entries.next().is_none() {
let _ = fs::remove_dir(d);
}
}
}
}
}
pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() {
return Ok(Vec::new());
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
}
let vault_root = Path::new(vault_path);
let mut notes = Vec::new();
let mut notebooks = Vec::new();
for entry in fs::read_dir(&trash_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
@@ -685,11 +916,38 @@ pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
if let Ok(note) = read_note_entry(&path, vault_root) {
notes.push(note);
}
} else if path.is_dir() {
let note_count = WalkDir::new(&path)
.min_depth(1)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file() && e.path().extension().and_then(|x| x.to_str()) == Some("md"))
.count();
let dirname = path.file_name().unwrap_or_default().to_string_lossy();
// Strip timestamp prefix to get original notebook name
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
dirname[18..].to_string()
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
dirname[15..].to_string()
} else {
dirname.to_string()
};
let modified = fs::metadata(&path)
.and_then(|m| m.modified())
.map(|t| DateTime::<Utc>::from(t))
.unwrap_or_else(|_| Utc::now());
notebooks.push(TrashNotebookEntry {
name,
path: path.to_string_lossy().to_string(),
note_count,
modified,
});
}
}
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes)
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(TrashContents { notes, notebooks })
}
pub fn restore_note(
@@ -717,18 +975,66 @@ pub fn restore_note(
&filename
};
let parent = src.parent().map(|p| p.to_path_buf());
let dest = dest_dir.join(original_name);
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(dest.to_string_lossy().to_string())
}
pub fn permanent_delete(path: &str) -> Result<(), String> {
pub fn restore_notebook(vault_path: &str, trash_path: &str) -> Result<String, String> {
let src = Path::new(trash_path);
if !src.exists() || !src.is_dir() {
return Err("Trashed notebook does not exist".to_string());
}
let dirname = src.file_name().unwrap_or_default().to_string_lossy();
// Try to read original path from sidecar .meta file
let meta_path = src.with_extension("").with_file_name(format!("{}.meta", dirname));
let relative = if let Ok(original) = fs::read_to_string(&meta_path) {
original
} else {
// Fallback for notebooks trashed before .meta files existed:
// strip timestamp prefix to get notebook name, restore to root
let name = if dirname.len() > 18 && dirname.chars().nth(17) == Some('_') {
&dirname[18..]
} else if dirname.len() > 15 && dirname.chars().nth(14) == Some('_') {
&dirname[15..]
} else {
&dirname
};
name.to_string()
};
let dest = Path::new(vault_path).join(&relative);
// Recreate parent directories if needed
if let Some(parent) = dest.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
// Clean up .meta file
let _ = fs::remove_file(&meta_path);
Ok(dest.to_string_lossy().to_string())
}
pub fn permanent_delete(vault_path: &str, path: &str) -> Result<(), String> {
let p = Path::new(path);
let parent = p.parent().map(|pp| pp.to_path_buf());
if p.is_dir() {
fs::remove_dir_all(p).map_err(|e| e.to_string())?;
} else {
fs::remove_file(p).map_err(|e| e.to_string())?;
}
// Clean up empty parent directory (deleted notebook folder) in trash
cleanup_empty_trash_dir(vault_path, parent.as_deref());
Ok(())
}

Some files were not shown because too many files have changed in this diff Show More