Compare commits

..
159 changed files with 1755 additions and 20672 deletions
+1 -17
View File
@@ -1,10 +1,5 @@
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
@@ -13,20 +8,9 @@ bun.lockb
/.svelte-kit
/build
# Nix
/result
# Tauri
/src-tauri/target
# 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
/src-tauri/gen
# OS
.DS_Store
+23 -139
View File
@@ -1,147 +1,39 @@
# 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.3.1-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-first markdown note-taking app built with Tauri, SvelteKit, and Rust.
A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem. No cloud, no lock-in.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download
## Download (v1.3.1)
| Platform | Download |
|----------|----------|
| Linux | [HelixNotes_1.0.0_amd64.AppImage](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.0.0/HelixNotes_1.0.0_amd64.AppImage) |
| Windows | Coming soon |
| macOS | Coming soon |
### Linux
#### Arch / Manjaro (AUR)
```bash
yay -S helixnotes-appimage-bin
```
#### Debian / Ubuntu / Mint (APT)
```bash
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
<details>
<summary>flake.nix</summary>
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
helix-notes = {
url = "git+https://codeberg.org/ArkHost/HelixNotes";
# inputs.nixpkgs.follows = "nixpkgs";
}
};
outputs = {
nixpkgs,
helix-notes,
...
}: let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
system = system;
specialArgs = { inherit helix-notes; };
modules = [
/path/to/configuration.nix
];
};
};
}
```
</details>
<details>
<summary>configuration.nix</summary>
```nix
{
config,
lib,
pkgs,
helix-notes,
...
}:
{
users.users.<USERNAME> = {
packages = with pkgs; [
(helix-notes.packages.${pkgs.stdenv.hostPlatform.system}.default)
];
};
}
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.1/HelixNotes_1.3.1_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_amd64.deb) (Ubuntu 22.04+)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.1/HelixNotes_1.3.1_android.apk)
---
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
See all releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases)
## Features
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
- Manual notebook sorting (drag to reorder above, into, or below)
- External `.md` viewer mode with import-to-vault flow
- PDF preview, Obsidian import, "Show in File Manager"
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
- Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
- Local plain-text files, no company cloud
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
- **Markdown editor** with rich formatting toolbar, slash commands, and source mode
- **Wiki-links** — link notes with `[[Note Title]]` syntax
- **Graph view** — visualize connections between your notes
- **Full-text search** powered by Tantivy
- **AI writing tools** — improve, summarize, translate, and more (Anthropic / OpenAI)
- **Version history** — per-note snapshots with diff view
- **Backups** — automatic zip-based vault backups
- **PDF preview** — inline rendering of embedded PDFs
- **Obsidian import** — convert Obsidian wiki-links to standard markdown
- **Themes** — light/dark mode with customizable accent colors and fonts
- **Focus mode** — distraction-free writing
- **Local-first** — everything stays on your machine
## Tech Stack
- **Frontend**: SvelteKit (Svelte 5) + TailwindCSS v4 + TipTap v3
- **Backend**: Rust (Tauri 2.0) + Tantivy (search) + Notify (file watcher)
- **Platforms**: Linux (AppImage), Windows, macOS, Android
- **Platforms**: Linux (AppImage), Windows, macOS
## Building from Source
@@ -165,14 +57,6 @@ pnpm tauri dev
pnpm tauri build
```
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Tasks calendar](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph view](https://cdn.helixnotes.com/assets/screenshots/screenshot-7.png)
![Daily notes](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
![Themes](https://cdn.helixnotes.com/assets/screenshots/screenshot-6.png)
## License
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[AGPL-3.0](LICENSE)
Generated
-61
View File
@@ -1,61 +0,0 @@
{
"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
@@ -1,76 +0,0 @@
{
description = "HelixNotes (local Markdown notes app) with Nix flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name;
version = cargoToml.package.version;
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
inherit pname version;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
};
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
pnpmConfigHook
pnpm
nodejs
pkg-config
jq
moreutils
wrapGAppsHook3
];
buildInputs = with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
];
cargoRoot = "src-tauri";
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
# Deactivate the upstream update mechanism
postPatch = ''
jq '
.bundle.createUpdaterArtifacts = false |
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
' \
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
'';
meta = with pkgs.lib; {
description = "HelixNotes desktop application";
homepage = "https://helixnotes.com/";
license = licenses.agpl3Plus;
platforms = platforms.linux ++ platforms.darwin;
mainProgram = "helixnotes";
};
};
formatter = pkgs.alejandra;
});
}
+2 -5
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.1",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
@@ -57,15 +57,12 @@
"@tiptap/extension-underline": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"@types/katex": "^0.16.8",
"@types/markdown-it": "^14.1.2",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"markdown-it": "^14.1.0",
"markdown-it-mark": "^4.0.0",
"markdown-it-sub": "^2.0.0",
"markdown-it-sup": "^2.0.0",
"markdown-it-task-lists": "^2.1.1",
"mermaid": "^11.14.0"
"markdown-it-task-lists": "^2.1.1"
}
}
-962
View File
File diff suppressed because it is too large Load Diff
+386 -875
View File
File diff suppressed because it is too large Load Diff
+5 -16
View File
@@ -1,7 +1,7 @@
[package]
name = "helixnotes"
version = "1.3.1"
description = "Local markdown note-taking app"
version = "1.0.1"
description = "Local-first markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
edition = "2021"
@@ -20,14 +20,14 @@ tauri-plugin-log = "2"
tauri-plugin-dialog = "2"
tauri-plugin-fs = "2"
tauri-plugin-opener = "2"
tauri-plugin-updater = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
serde_yaml = "0.9"
log = "0.4"
notify = "8"
walkdir = "2"
chrono = { version = "0.4", features = ["serde", "unstable-locales"] }
sys-locale = "0.3"
chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4"] }
tantivy = "0.22"
gray_matter = "0.2"
@@ -35,17 +35,6 @@ 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", "blocking"] }
reqwest = { version = "0.12", features = ["json", "stream"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
arboard = { version = "3", features = ["image-data", "wayland-data-control"] }
png = "0.17"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp", "gif"] }
+3 -7
View File
@@ -2,21 +2,16 @@
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "HelixNotes default capabilities",
"windows": ["main", "note-*"],
"windows": ["main"],
"permissions": [
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-set-decorations",
"core:window:allow-is-fullscreen",
"core:window:allow-set-fullscreen",
"core:window:allow-set-title",
"core:webview:allow-create-webview-window",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
@@ -49,6 +44,7 @@
{ "url": "mailto:*" }
]
},
"opener:allow-reveal-item-in-dir"
"opener:allow-reveal-item-in-dir",
"updater:default"
]
}
-12
View File
@@ -1,12 +0,0 @@
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "desktop",
"description": "Desktop-only capabilities",
"platforms": ["linux", "macOS", "windows"],
"windows": ["main"],
"permissions": [
"updater:default",
"window-state:default",
"core:webview:allow-set-webview-zoom"
]
}
-12
View File
@@ -1,12 +0,0 @@
# 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
@@ -1,19 +0,0 @@
*.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
@@ -1,6 +0,0 @@
/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
@@ -1,92 +0,0 @@
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
@@ -1,22 +0,0 @@
# 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.**
@@ -1,42 +0,0 @@
<?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>
@@ -1,157 +0,0 @@
package com.helixnotes.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.MimeTypeMap
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
class MainActivity : TauriActivity() {
private var webView: WebView? = null
companion object {
private const val STORAGE_PERMISSION_REQUEST_CODE = 1001
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
this.webView = webView
webView.addJavascriptInterface(StorageBridge(this), "Android")
// Render text at the size CSS specifies, ignoring the device's system font-size
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
webView.settings.textZoom = 100
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
}
override fun onResume() {
super.onResume()
if (hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun hasStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+): "All files access" special permission.
Environment.isExternalStorageManager()
} else {
// Android 7-10 (API 24-29): legacy runtime storage permission.
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
}
fun requestStoragePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) return
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
try {
val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
startActivity(fallback)
} catch (_: Exception) {
val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
details.data = Uri.parse("package:$packageName")
startActivity(details)
}
}
} else {
// Android 7-10: request the legacy runtime permission directly.
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
STORAGE_PERMISSION_REQUEST_CODE
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun prepareVaultDir(path: String): Boolean {
return try {
val dir = File(path)
dir.mkdirs()
File(dir, ".helixnotes/trash").mkdirs()
File(dir, ".helixnotes/attachments").mkdirs()
dir.exists() && dir.canWrite()
} catch (e: Exception) {
false
}
}
fun openFile(path: String) {
try {
val file = File(path)
if (!file.exists()) return
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("HelixNotes", "Failed to open file: $path", e)
}
}
}
class StorageBridge(private val activity: MainActivity) {
@JavascriptInterface
fun hasStoragePermission(): Boolean {
return activity.hasStoragePermission()
}
@JavascriptInterface
fun requestStoragePermission() {
activity.runOnUiThread {
activity.requestStoragePermission()
}
}
@JavascriptInterface
fun prepareVaultDir(path: String): Boolean {
return activity.prepareVaultDir(path)
}
@JavascriptInterface
fun openFile(path: String) {
activity.runOnUiThread {
activity.openFile(path)
}
}
}
@@ -1,30 +0,0 @@
<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>
@@ -1,10 +0,0 @@
<?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>
@@ -1,18 +0,0 @@
<?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>
@@ -1,5 +0,0 @@
<?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.

Before

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

@@ -1,8 +0,0 @@
<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>
@@ -1,10 +0,0 @@
<?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>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
@@ -1,4 +0,0 @@
<resources>
<string name="app_name">HelixNotes</string>
<string name="main_activity_title">HelixNotes</string>
</resources>
@@ -1,9 +0,0 @@
<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>
@@ -1,5 +0,0 @@
<?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
@@ -1,22 +0,0 @@
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")
}
@@ -1,23 +0,0 @@
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")
}
@@ -1,68 +0,0 @@
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()
}
}
@@ -1,85 +0,0 @@
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
@@ -1,24 +0,0 @@
# 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.
@@ -1,6 +0,0 @@
#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
@@ -1,185 +0,0 @@
#!/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
@@ -1,89 +0,0 @@
@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
@@ -1,3 +0,0 @@
include ':app'
apply from: 'tauri.settings.gradle'
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 953 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 793 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 2.8 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: 168 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

+22 -88
View File
@@ -6,7 +6,6 @@ use crate::types::AiStreamEvent;
const ANTHROPIC_API_URL: &str = "https://api.anthropic.com/v1/messages";
const OPENAI_API_URL: &str = "https://api.openai.com/v1/chat/completions";
const OLLAMA_DEFAULT_URL: &str = "http://localhost:11434";
pub fn ai_request(
app: AppHandle,
@@ -16,19 +15,15 @@ pub fn ai_request(
system_prompt: String,
user_message: String,
request_id: String,
base_url: Option<String>,
) {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
let result = match provider.as_str() {
"openai" => {
stream_openai(
&app,
OPENAI_API_URL,
Some(&api_key),
&api_key,
&model,
&system_prompt,
&user_message,
@@ -36,38 +31,6 @@ pub fn ai_request(
)
.await
}
"ollama" => {
let url = base_url.as_deref().unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
stream_openai(
&app,
&url,
key_opt,
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
"openai_compatible" => {
let url = base_url.as_deref().unwrap_or("");
if url.is_empty() {
Err("No base URL configured for OpenAI Compatible provider".to_string())
} else {
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
stream_openai(
&app,
&url,
key_opt,
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
}
_ => {
stream_anthropic(
&app,
@@ -94,13 +57,6 @@ pub fn ai_request(
});
}
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
}
async fn stream_anthropic(
app: &AppHandle,
api_key: &str,
@@ -217,6 +173,7 @@ async fn stream_anthropic(
}
}
// Stream ended — send done
let _ = app.emit(
"ai-stream",
AiStreamEvent {
@@ -231,8 +188,7 @@ async fn stream_anthropic(
async fn stream_openai(
app: &AppHandle,
url: &str,
api_key: Option<&str>,
api_key: &str,
model: &str,
system_prompt: &str,
user_message: &str,
@@ -268,15 +224,10 @@ async fn stream_openai(
body["temperature"] = json!(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
let response = client
.post(OPENAI_API_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("content-type", "application/json")
.json(&body)
.send()
.await
@@ -374,28 +325,9 @@ async fn stream_openai(
Ok(())
}
pub async fn test_connection(
provider: &str,
api_key: &str,
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
pub async fn test_connection(provider: &str, api_key: &str, model: &str) -> Result<String, String> {
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, key_opt, model).await
}
"openai_compatible" => {
let url = base_url.unwrap_or("");
if url.is_empty() {
return Err("No base URL configured for OpenAI Compatible provider".to_string());
}
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
test_openai(&url, key_opt, model).await
}
"openai" => test_openai(api_key, model).await,
_ => test_anthropic(api_key, model).await,
}
}
@@ -433,12 +365,19 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
}
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
async fn test_openai(api_key: &str, model: &str) -> Result<String, String> {
let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 {
"max_completion_tokens"
} else {
"max_tokens"
};
let body = json!({
"model": model,
"max_tokens": 10,
token_key: 10,
"messages": [
{
"role": "user",
@@ -447,15 +386,10 @@ async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<St
]
});
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
let response = client
.post(OPENAI_API_URL)
.header("Authorization", format!("Bearer {}", api_key))
.header("content-type", "application/json")
.json(&body)
.send()
.await
+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;
}
+45 -1106
View File
File diff suppressed because it is too large Load Diff
+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 {
+6 -226
View File
@@ -4,37 +4,21 @@ mod commands;
mod history;
mod search;
mod state;
mod sync;
mod types;
mod vault;
use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
#[cfg(desktop)]
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
tray::TrayIconBuilder,
Manager,
};
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
#[cfg(target_os = "linux")]
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let config = commands::load_app_config();
#[cfg(desktop)]
let show_tray = config.show_tray_icon;
#[cfg(desktop)]
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config);
@@ -42,6 +26,7 @@ pub fn run() {
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_updater::Builder::new().build())
.setup(move |app| {
if cfg!(debug_assertions) {
app.handle().plugin(
@@ -51,93 +36,41 @@ pub fn run() {
)?;
}
// On mobile, set config dir from Tauri's path resolver, then reload config
#[cfg(mobile)]
{
if let Ok(config_dir) = app.path().config_dir() {
commands::set_mobile_config_dir(config_dir);
} else if let Ok(data_dir) = app.path().data_dir() {
commands::set_mobile_config_dir(data_dir);
}
// Reload config now that the mobile config dir is available
let reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded;
});
}
#[cfg(desktop)]
if show_tray {
setup_tray(app)?;
}
// Check CLI args for a .md file path on initial launch
#[cfg(desktop)]
{
let file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_arg {
let resolved = if std::path::Path::new(&path).is_absolute() {
std::path::PathBuf::from(&path)
} else {
std::env::current_dir().unwrap_or_default().join(&path)
};
if let Some(resolved_str) = resolved.to_str() {
let app_state = app.state::<AppState>();
let _ = app_state.pending_open_file.lock().map(|mut p| {
*p = Some(resolved_str.to_string());
});
}
}
}
Ok(())
})
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::open_vault,
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_accent_color,
commands::set_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
commands::rename_notebook,
commands::delete_notebook,
commands::move_notebook,
commands::get_notes,
commands::read_note,
commands::save_note,
commands::create_note,
commands::create_daily_note,
commands::rename_note,
commands::delete_note,
commands::move_note,
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
commands::set_task_priority,
commands::set_task_due,
commands::search_notes,
commands::reindex,
commands::get_trash,
commands::restore_note,
commands::restore_notebook,
commands::permanent_delete,
commands::empty_trash,
commands::load_vault_state,
commands::save_vault_state,
commands::read_clipboard_image,
commands::copy_image_to_clipboard,
commands::save_image,
commands::save_attachment,
commands::get_notebook_icons,
@@ -146,16 +79,10 @@ pub fn run() {
commands::get_quick_access,
commands::add_quick_access,
commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats,
commands::find_orphaned_attachments,
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::open_url,
commands::copy_file_to,
commands::write_bytes_to,
commands::copy_png_to_clipboard,
commands::create_backup,
commands::list_backups,
commands::restore_backup,
@@ -167,140 +94,14 @@ pub fn run() {
commands::set_ai_settings,
commands::test_ai_connection,
commands::ai_ask,
commands::set_sync_settings,
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type,
commands::is_mobile_platform,
commands::get_pending_open_file,
])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
let path = request.uri().path().to_string();
std::thread::spawn(move || {
let encoded = path.strip_prefix('/').unwrap_or(&path);
let external_url = percent_decode(encoded);
]);
if !external_url.starts_with("http://") && !external_url.starts_with("https://") {
let _ = responder.respond(
tauri::http::Response::builder()
.status(400)
.body(Vec::new())
.unwrap(),
);
return;
}
let client = match reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
{
Ok(c) => c,
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
return;
}
};
match client.get(&external_url).send() {
Ok(resp) => {
let content_type = resp
.headers()
.get("content-type")
.and_then(|h| h.to_str().ok())
.unwrap_or("application/octet-stream")
.to_string();
let status = resp.status().as_u16();
match resp.bytes() {
Ok(bytes) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(status)
.header("Content-Type", &content_type)
.header("Access-Control-Allow-Origin", "*")
.body(bytes.to_vec())
.unwrap(),
);
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
}
Err(_) => {
let _ = responder.respond(
tauri::http::Response::builder()
.status(502)
.body(Vec::new())
.unwrap(),
);
}
}
});
});
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window
if let Some(window) = app.get_webview_window("main") {
let _ = window.show();
let _ = window.unminimize();
let _ = window.set_focus();
}
// Extract .md file path from args (args[0] is the binary)
let file_path = args.iter().skip(1).find(|arg| {
let a = arg.trim();
!a.starts_with('-') && a.ends_with(".md")
});
if let Some(path) = file_path {
let resolved = if std::path::Path::new(path.as_str()).is_absolute() {
std::path::PathBuf::from(path)
} else {
std::path::Path::new(&cwd).join(path)
};
if let Some(resolved_str) = resolved.to_str() {
let _ = app.emit("open-file", resolved_str.to_string());
}
}
}));
builder = builder.plugin(tauri_plugin_updater::Builder::new().build());
builder = builder.plugin(tauri_plugin_window_state::Builder::default().build());
builder = builder.on_window_event(move |window, event| {
match event {
tauri::WindowEvent::CloseRequested { api, .. } => {
// Only hide to tray for the main window
if close_to_tray && window.label() == "main" {
if close_to_tray {
builder = builder.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event {
api.prevent_close();
let _ = window.hide();
}
}
tauri::WindowEvent::Destroyed => {
// When main window is destroyed, close all note windows
if window.label() == "main" {
let app = window.app_handle();
for (label, win) in app.webview_windows() {
if label.starts_with("note-") {
let _ = win.close();
}
}
}
}
_ => {}
}
});
}
@@ -309,7 +110,6 @@ pub fn run() {
.expect("error while running tauri application");
}
#[cfg(desktop)]
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
@@ -348,23 +148,3 @@ 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()
}
+22 -275
View File
@@ -5,163 +5,12 @@ use std::fs;
use std::path::Path;
use std::sync::Mutex;
use tantivy::collector::TopDocs;
#[cfg(desktop)]
use tantivy::directory::MmapDirectory;
#[cfg(mobile)]
use tantivy::directory::RamDirectory;
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
use tantivy::query::QueryParser;
use tantivy::schema::*;
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use tantivy::{Index, IndexWriter, TantivyDocument};
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>>,
@@ -175,75 +24,22 @@ pub struct SearchIndex {
impl SearchIndex {
pub fn new(vault_path: &str) -> Result<Self, String> {
let index_dir = helixnotes_dir(vault_path).join("search_index");
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let mut schema_builder = Schema::builder();
let path_field = schema_builder.add_text_field("path", STRING | STORED);
// 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 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);
let schema = schema_builder.build();
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
// Desktop: use mmap directory for persistent index on disk
#[cfg(mobile)]
let index = {
let dir = RamDirectory::create();
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
};
#[cfg(desktop)]
let index = {
let base = vault_index_base(vault_path)
.unwrap_or_else(|| helixnotes_dir(vault_path).join("search_index"));
let index_dir = base.join("index");
let version_path = base.join("version");
// Remove the old in-vault index so it stops syncing.
let hn = helixnotes_dir(vault_path);
let _ = fs::remove_dir_all(hn.join("search_index"));
let _ = fs::remove_file(hn.join("search_index.version"));
// One-time wipe when the schema/tokenizer version changes; rebuild() repopulates.
let version_ok = fs::read_to_string(&version_path)
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
.unwrap_or(false);
if !version_ok {
let _ = fs::remove_dir_all(&index_dir);
}
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
let idx = match Index::open_or_create(dir, schema.clone()) {
Ok(idx) => idx,
Err(_) => {
// Schema mismatch (older index) or corruption: wipe and recreate.
let _ = fs::remove_dir_all(&index_dir);
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
}
};
let _ = fs::write(&version_path, INDEX_SCHEMA_VERSION);
idx
};
let index = Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?;
// Register the CJK-aware tokenizer (in-memory; must be done on every open,
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
);
#[cfg(mobile)]
let heap_size = 15_000_000;
#[cfg(desktop)]
let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
let writer = index
.writer(50_000_000)
.map_err(|e| e.to_string())?;
Ok(Self {
index,
@@ -266,20 +62,18 @@ impl SearchIndex {
for entry in WalkDir::new(vault_path)
.into_iter()
.filter_entry(|e| {
.filter_map(|e| e.ok())
.filter(|e| {
let p = e.path();
!p.starts_with(&hn_dir)
p.is_file()
&& p.extension().and_then(|x| x.to_str()) == Some("md")
&& !p.starts_with(&hn_dir)
&& !p
.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with('.'))
.unwrap_or(false)
})
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{
if let Ok(raw) = fs::read_to_string(entry.path()) {
let filename = entry.file_name().to_string_lossy().to_string();
@@ -341,59 +135,12 @@ impl SearchIndex {
let reader = self.index.reader().map_err(|e| e.to_string())?;
let searcher = reader.searcher();
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_parser =
QueryParser::for_index(&self.index, vec![self.title_field, self.body_field, self.tags_field]);
// 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 query = query_parser
.parse_query(query_str)
.map_err(|e| e.to_string())?;
let top_docs = searcher
.search(&query, &TopDocs::with_limit(limit))
+1 -6
View File
@@ -3,15 +3,12 @@ use crate::types::AppConfig;
use notify::RecommendedWatcher;
use std::sync::atomic::AtomicBool;
use std::sync::Mutex;
use std::sync::Arc;
pub struct AppState {
pub config: Mutex<AppConfig>,
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub search_index: Mutex<Option<SearchIndex>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
}
impl AppState {
@@ -21,8 +18,6 @@ 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),
}
}
}
-710
View File
@@ -1,710 +0,0 @@
// WebDAV sync provider.
//
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
// records what was in sync last time, so we can do a three-way diff (local vs remote
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{Emitter, Manager};
use walkdir::WalkDir;
// ───────────────────────── config ─────────────────────────
pub struct WebdavConfig {
pub url: String,
pub username: String,
pub password: String,
}
#[derive(Default, Clone, Serialize)]
pub struct SyncSummary {
pub uploaded: u32,
pub downloaded: u32,
pub deleted_local: u32,
pub deleted_remote: u32,
pub conflicts: u32,
}
// ───────────────────────── manifest ─────────────────────────
#[derive(Default, Serialize, Deserialize)]
struct SyncManifest {
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
}
#[derive(Clone, Serialize, Deserialize)]
struct ManifestEntry {
local_hash: String, // sha256 of local content at last sync
remote_etag: String, // remote etag at last sync
}
fn manifest_path(vault: &str) -> PathBuf {
helixnotes_dir(vault).join("sync_state.json")
}
fn load_manifest(vault: &str) -> SyncManifest {
fs::read_to_string(manifest_path(vault))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_manifest(vault: &str, m: &SyncManifest) {
if let Ok(s) = serde_json::to_string(m) {
let _ = fs::write(manifest_path(vault), s);
}
}
// ───────────────────────── helpers ─────────────────────────
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
let mut s = String::with_capacity(64);
for b in digest {
s.push_str(&format!("{:02x}", b));
}
s
}
fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
}
/// Percent-encode each path segment, keeping the `/` separators.
fn encode_path(relpath: &str) -> String {
relpath
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/")
}
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
let (dir, name) = match relpath.rfind('/') {
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
None => ("", relpath),
};
let (stem, ext) = match name.rfind('.') {
Some(i) => (&name[..i], &name[i..]),
None => (name, ""),
};
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
}
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
let target = vault.join(relpath);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&target, bytes).map_err(|e| e.to_string())
}
struct LocalFile {
hash: String,
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
} else {
rel.ends_with(".md")
}
}
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
let vault_path = Path::new(vault);
let mut map = HashMap::new();
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if !p.is_file() {
continue;
}
let rel = match p.strip_prefix(vault_path) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => continue,
};
if !is_synced_relpath(&rel) {
continue;
}
let bytes = match fs::read(p) {
Ok(b) => b,
Err(_) => continue,
};
map.insert(
rel,
LocalFile {
hash: sha256_hex(&bytes),
path: p.to_path_buf(),
},
);
}
Ok(map)
}
// ───────────────────────── WebDAV client ─────────────────────────
struct RemoteEntry {
relpath: String,
etag: String,
is_dir: bool,
}
struct WebdavClient {
base: String, // normalized, no trailing slash
base_path: String, // path component of base, no trailing slash (percent-encoded)
client: reqwest::blocking::Client,
user: String,
pass: String,
}
impl WebdavClient {
fn new(cfg: WebdavConfig) -> Result<Self, String> {
let base = cfg.url.trim().trim_end_matches('/').to_string();
if !base.starts_with("http://") && !base.starts_with("https://") {
return Err("WebDAV URL must start with http:// or https://".to_string());
}
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
let base_path = base_url.path().trim_end_matches('/').to_string();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
Ok(Self {
base,
base_path,
client,
user: cfg.username,
pass: cfg.password,
})
}
fn url_for(&self, relpath: &str) -> String {
if relpath.is_empty() {
self.base.clone()
} else {
format!("{}/{}", self.base, encode_path(relpath))
}
}
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.header("Depth", depth)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.map_err(|e| e.to_string())?;
let status = resp.status();
if status.as_u16() == 401 {
return Err("Authentication failed (check username / app password)".to_string());
}
if status.as_u16() == 404 {
return Err("WebDAV path not found (check the URL)".to_string());
}
if !status.is_success() {
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
}
let text = resp.text().map_err(|e| e.to_string())?;
parse_multistatus(&text, &self.base_path)
}
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
/// (Depth: infinity is disabled on some servers, so we walk instead).
fn list(&self) -> Result<HashMap<String, String>, String> {
let mut files = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = vec![String::new()];
while let Some(dir) = queue.pop() {
if !visited.insert(dir.clone()) {
continue;
}
for e in self.propfind(&dir, "1")? {
if e.relpath == dir {
continue; // the collection itself
}
if e.is_dir {
if !visited.contains(&e.relpath) {
queue.push(e.relpath);
}
} else {
files.insert(e.relpath, e.etag);
}
}
}
Ok(files)
}
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
let resp = self
.client
.get(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
}
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
let resp = self
.client
.put(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.body(bytes.to_vec())
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp
.headers()
.get("etag")
.and_then(|h| h.to_str().ok())
.map(normalize_etag))
}
fn delete(&self, relpath: &str) -> Result<(), String> {
let resp = self
.client
.delete(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status();
if s.is_success() || s.as_u16() == 404 {
Ok(())
} else {
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
}
}
fn mkcol(&self, relpath: &str) -> Result<(), String> {
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status().as_u16();
// 201 created; 405 already exists; 301 redirect-to-existing.
if resp.status().is_success() || s == 405 || s == 301 {
Ok(())
} else {
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
}
}
/// Ensure every ancestor collection of `relpath` exists on the remote.
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
let parts: Vec<&str> = relpath.split('/').collect();
if parts.len() <= 1 {
return Ok(());
}
let mut cur = String::new();
for part in &parts[..parts.len() - 1] {
if !cur.is_empty() {
cur.push('/');
}
cur.push_str(part);
if created.insert(cur.clone()) {
self.mkcol(&cur)?;
}
}
Ok(())
}
fn put_with_dirs(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash).
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
/// unmappable href makes that file look deleted on the remote, and the reconciler then
/// wipes the local copy. (issue #102)
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
// Pull out just the path component (some servers return absolute-URL hrefs).
let raw = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href)
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
.path()
.to_string()
} else {
href.to_string()
};
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
// both sides first makes the prefix match encoding-agnostic. (issue #102)
let path = urlencoding::decode(raw.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
.into_owned();
let base = urlencoding::decode(base_path.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
.into_owned();
let rest = match path.strip_prefix(&base) {
Some(r) => r.trim_start_matches('/'),
None => {
return Err(format!(
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
Aborting before deleting anything locally - please report this URL."
))
}
};
if rest.is_empty() {
return Ok(None); // the base collection itself
}
Ok(Some(rest.to_string()))
}
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
#[derive(PartialEq)]
enum Cap {
None,
Href,
Etag,
}
let mut reader = Reader::from_str(xml);
let mut entries = Vec::new();
let mut href: Option<String> = None;
let mut etag: Option<String> = None;
let mut is_dir = false;
let mut cap = Cap::None;
let mut buf = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => match e.local_name().as_ref() {
b"response" => {
href = None;
etag = None;
is_dir = false;
}
b"href" => {
cap = Cap::Href;
buf.clear();
}
b"getetag" => {
cap = Cap::Etag;
buf.clear();
}
b"collection" => is_dir = true,
_ => {}
},
Ok(Event::Empty(e)) => {
if e.local_name().as_ref() == b"collection" {
is_dir = true;
}
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
}
}
}
Ok(Event::End(e)) => match e.local_name().as_ref() {
b"href" => {
href = Some(buf.clone());
cap = Cap::None;
}
b"getetag" => {
etag = Some(buf.clone());
cap = Cap::None;
}
b"response" => {
if let Some(h) = href.take() {
if let Some(rel) = href_to_relpath(&h, base_path)? {
entries.push(RemoteEntry {
relpath: rel,
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
is_dir,
});
}
}
}
_ => {}
},
Ok(Event::Eof) => break,
Err(e) => return Err(format!("XML parse error: {e}")),
_ => {}
}
}
Ok(entries)
}
// ───────────────────────── sync engine ─────────────────────────
fn apply_changes(
client: &WebdavClient,
vault: &str,
local: &HashMap<String, LocalFile>,
remote: &HashMap<String, String>,
manifest: &SyncManifest,
) -> Result<(SyncManifest, SyncSummary), String> {
let vault_path = Path::new(vault);
let mut new_m = SyncManifest::default();
let mut sum = SyncSummary::default();
let mut created: HashSet<String> = HashSet::new();
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
let mut keys: HashSet<&String> = HashSet::new();
keys.extend(local.keys());
keys.extend(remote.keys());
keys.extend(manifest.files.keys());
for key in keys {
let l = local.get(key);
let r = remote.get(key);
let m = manifest.files.get(key);
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
if !local_changed && !remote_changed {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
},
);
sum.uploaded += 1;
} else if !local_changed && remote_changed {
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// both changed: compare contents; identical => no real conflict.
let rbytes = client.get(key)?;
let rhash = sha256_hex(&rbytes);
if rhash == lf.hash {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else {
// keep-both: remote becomes canonical; local saved as a conflict copy.
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
write_local(vault_path, key, &rbytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: rhash,
remote_etag: re.clone(),
},
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
},
);
sum.conflicts += 1;
}
}
}
(Some(lf), None) => match m {
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
}
Some(me) => {
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
} else {
// remote deleted, local unchanged -> delete local
let _ = fs::remove_file(&lf.path);
sum.deleted_local += 1;
}
}
},
(None, Some(re)) => match m {
None => {
// new remote -> download
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
}
Some(me) => {
if &me.remote_etag != re {
// local deleted but remote changed -> resurrect (download)
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// local deleted, remote unchanged -> delete remote
client.delete(key)?;
sum.deleted_remote += 1;
}
}
},
(None, None) => {
// gone on both sides -> drop from manifest
}
}
}
Ok((new_m, sum))
}
/// Validate the connection (auth + URL) with a cheap PROPFIND.
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
let client = WebdavClient::new(cfg)?;
client.propfind("", "0")?;
Ok("Connection successful".to_string())
}
/// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering;
let state = app.state::<AppState>();
let client = WebdavClient::new(cfg)?;
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
let remote: HashMap<String, String> = client
.list()?
.into_iter()
.filter(|(k, _)| is_synced_relpath(k))
.collect();
let local = scan_local(&vault)?;
let manifest = load_manifest(&vault);
// Safety guard (issue #102): never treat an empty remote listing as "everything was
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
// map), not a real mass-deletion - so abort instead of wiping local notes.
if remote.is_empty() && !manifest.files.is_empty() {
return Err(
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
even though files were synced before, so no local files were deleted. This usually \
means the server reports paths in a format the app can't read yet - please report \
your WebDAV provider and folder URL."
.to_string(),
);
}
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
state.importing.store(true, Ordering::Relaxed);
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
state.importing.store(false, Ordering::Relaxed);
let (new_manifest, summary) = applied?;
save_manifest(&vault, &new_manifest);
// Re-index after files changed (search is non-critical; ignore failures).
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.rebuild(&vault);
}
}
// The watcher was muted during apply, so nudge the UI to refresh its lists.
let _ = app.emit(
"file-changed",
crate::types::FileEvent {
event_type: "modify".to_string(),
path: vault.clone(),
},
);
Ok(summary)
}
+2 -143
View File
@@ -19,20 +19,6 @@ 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,
@@ -68,19 +54,9 @@ pub struct AppConfig {
#[serde(default)]
pub font_family: Option<String>,
#[serde(default)]
pub line_height: Option<f64>,
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default = "default_daily_title_format")]
pub daily_title_format: String,
#[serde(default)]
pub gpu_acceleration: bool,
#[serde(default)]
@@ -94,8 +70,6 @@ pub struct AppConfig {
#[serde(default)]
pub hide_title_in_body: bool,
#[serde(default)]
pub show_line_numbers: bool,
#[serde(default)]
pub backup_enabled: bool,
#[serde(default = "default_backup_frequency")]
pub backup_frequency: String,
@@ -115,14 +89,6 @@ pub struct AppConfig {
pub ai_api_key: Option<String>,
#[serde(default)]
pub openai_api_key: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub ollama_api_key: Option<String>,
#[serde(default)]
pub openai_compatible_base_url: Option<String>,
#[serde(default)]
pub openai_compatible_api_key: Option<String>,
#[serde(default = "default_ai_model")]
pub ai_model: String,
#[serde(default)]
@@ -135,25 +101,6 @@ 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 {
@@ -176,20 +123,12 @@ fn default_title_mode() -> String {
"input".to_string()
}
fn default_week_start() -> String {
"monday".to_string()
}
fn default_daily_title_format() -> String {
"localized".to_string()
}
fn default_max_versions() -> u32 {
20
}
fn default_ai_model() -> String {
"claude-sonnet-4-6".to_string()
"claude-sonnet-4-5-20250929".to_string()
}
impl Default for AppConfig {
@@ -201,20 +140,14 @@ impl Default for AppConfig {
accent_color: None,
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(),
daily_title_format: "localized".to_string(),
gpu_acceleration: true,
autostart: false,
pdf_preview: false,
pdf_height: 600,
title_mode: "input".to_string(),
hide_title_in_body: false,
show_line_numbers: false,
backup_enabled: false,
backup_frequency: "24h".to_string(),
backup_max_count: 10,
@@ -225,25 +158,12 @@ impl Default for AppConfig {
ai_provider: None,
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_model: "claude-sonnet-4-5-20250929".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,
}
}
}
@@ -256,26 +176,6 @@ 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,
#[serde(default = "default_true")]
pub tasks_hide_completed: bool,
#[serde(default)]
pub tasks_only_flagged: bool,
#[serde(default)]
pub tasks_sort: String,
}
impl Default for VaultState {
@@ -286,16 +186,6 @@ 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(),
tasks_hide_completed: true,
tasks_only_flagged: false,
tasks_sort: String::new(),
}
}
}
@@ -355,34 +245,3 @@ pub struct NoteTitleEntry {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphData {
pub nodes: Vec<GraphNode>,
pub edges: Vec<GraphEdge>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphNode {
pub title: String,
pub path: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphEdge {
pub source: usize,
pub target: usize,
pub bidirectional: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskItem {
pub note_path: String,
pub note_title: String,
pub line: usize,
pub raw_line: String,
pub text: String,
pub completed: bool,
pub due: Option<String>,
pub priority: Option<String>,
}
+7 -117
View File
@@ -1,8 +1,9 @@
use crate::types::NoteMeta;
use chrono::{NaiveDate, NaiveDateTime, Utc};
use chrono::Utc;
use gray_matter::engine::YAML;
use gray_matter::Matter;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, Default)]
struct RawFrontmatter {
@@ -14,32 +15,6 @@ struct RawFrontmatter {
modified: Option<String>,
}
/// Try to parse a date string in multiple common formats.
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
let s = s.trim();
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
return Some(dt);
}
// Try parsing as DateTime with fixed offset then convert
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
return Some(dt.with_timezone(&Utc));
}
// YYYY-MM-DD HH:MM:SS (no timezone)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%d %H:%M:%S") {
return Some(ndt.and_utc());
}
// YYYY-MM-DDTHH:MM:SS (no timezone, with T separator)
if let Ok(ndt) = NaiveDateTime::parse_from_str(s, "%Y-%m-%dT%H:%M:%S") {
return Some(ndt.and_utc());
}
// YYYY-MM-DD (date only)
if let Ok(nd) = NaiveDate::parse_from_str(s, "%Y-%m-%d") {
return nd.and_hms_opt(0, 0, 0).map(|ndt| ndt.and_utc());
}
None
}
pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
let matter = Matter::<YAML>::new();
let result = matter.parse(raw);
@@ -53,16 +28,15 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
let created = fm
.created
.and_then(|s| parse_date_flexible(&s))
.and_then(|s| s.parse().ok())
.unwrap_or_else(Utc::now);
let modified = fm
.modified
.and_then(|s| parse_date_flexible(&s))
.and_then(|s| s.parse().ok())
.unwrap_or_else(Utc::now);
// Don't generate UUID on read - empty string signals "no ID yet"
let id = fm.id.unwrap_or_default();
let id = fm.id.unwrap_or_else(|| Uuid::new_v4().to_string());
let meta = NoteMeta {
id,
@@ -77,29 +51,6 @@ pub fn parse_note(raw: &str, filename: &str) -> (NoteMeta, String) {
(meta, content)
}
/// Ensure the body starts with `# Title` heading for external app compatibility.
/// If the body already starts with `# Title`, leave it as-is.
fn ensure_title_heading(body: &str, title: &str) -> String {
let trimmed = body.trim_start();
// Check if body already starts with `# Title`
if let Some(rest) = trimmed.strip_prefix("# ") {
let first_line_end = rest.find('\n').unwrap_or(rest.len());
let heading_text = rest[..first_line_end].trim();
if heading_text.eq_ignore_ascii_case(title.trim()) {
return body.to_string();
}
}
// Don't add heading to empty notes (new/blank notes)
if trimmed.is_empty() {
return body.to_string();
}
// Prepend `# Title` heading
format!("# {}\n\n{}", title, body)
}
pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
let tags_str = if meta.tags.is_empty() {
"[]".to_string()
@@ -127,68 +78,7 @@ pub fn serialize_frontmatter(meta: &NoteMeta) -> String {
pub fn update_note_raw(meta: &NoteMeta, body: &str) -> String {
let fm = serialize_frontmatter(meta);
let body_with_heading = ensure_title_heading(body, &meta.title);
format!("{}{}", fm, body_with_heading)
}
/// Merge NoteMeta fields into existing frontmatter, preserving unknown YAML keys.
/// Falls back to serialize_frontmatter() if the original has no frontmatter.
pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> String {
let matter = Matter::<YAML>::new();
let parsed = matter.parse(original_raw);
// If original had no frontmatter, use the simple serializer
if parsed.matter.trim().is_empty() {
return update_note_raw(meta, body);
}
// Parse existing YAML into a Mapping to preserve all keys
let mut mapping: serde_yaml::Mapping = match serde_yaml::from_str(&parsed.matter) {
Ok(m) => m,
Err(_) => return update_note_raw(meta, body), // unparseable YAML, fall back
};
// Update only our known fields
if !meta.id.is_empty() {
mapping.insert(
serde_yaml::Value::String("id".into()),
serde_yaml::Value::String(meta.id.clone()),
);
}
mapping.insert(
serde_yaml::Value::String("title".into()),
serde_yaml::Value::String(meta.title.clone()),
);
mapping.insert(
serde_yaml::Value::String("tags".into()),
serde_yaml::Value::Sequence(
meta.tags
.iter()
.map(|t| serde_yaml::Value::String(t.clone()))
.collect(),
),
);
mapping.insert(
serde_yaml::Value::String("pinned".into()),
serde_yaml::Value::Bool(meta.pinned),
);
mapping.insert(
serde_yaml::Value::String("created".into()),
serde_yaml::Value::String(meta.created.to_rfc3339()),
);
mapping.insert(
serde_yaml::Value::String("modified".into()),
serde_yaml::Value::String(meta.modified.to_rfc3339()),
);
// Serialize the mapping back to YAML
let yaml_str = match serde_yaml::to_string(&serde_yaml::Value::Mapping(mapping)) {
Ok(s) => s,
Err(_) => return update_note_raw(meta, body),
};
let body_with_heading = ensure_title_heading(body, &meta.title);
format!("---\n{}---\n{}", yaml_str, body_with_heading)
format!("{}{}", fm, body)
}
fn filename_to_title(filename: &str) -> String {
@@ -309,7 +199,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;
+34 -589
View File
@@ -1,6 +1,6 @@
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, TrashContents, TrashNotebookEntry, VaultState};
use crate::types::{NoteContent, NoteEntry, NoteMeta, NotebookEntry, VaultState};
use crate::vault::frontmatter;
use chrono::{DateTime, Local, Locale, Utc};
use chrono::Utc;
use rayon::prelude::*;
use std::fs;
use std::io::Read;
@@ -59,13 +59,11 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
return entries;
};
// Collect subdirs (root level only lists directories, note counts come from scan_dir_with_count)
let mut dirs: Vec<_> = read_dir
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)
&& !is_hidden(&e.path())
&& e.file_name().to_string_lossy() != "Daily"
let path = e.path();
path.is_dir() && !is_hidden(&path)
})
.collect();
@@ -76,8 +74,6 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.cmp(&b.file_name().to_string_lossy().to_lowercase())
});
// Store note_count for the parent directory (used when this is the top-level call)
// Each directory pushes its own entry with its own note_count
for dir_entry in dirs {
let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string();
@@ -87,165 +83,52 @@ fn scan_dir_recursive(dir: &Path, vault_root: &str) -> Vec<NotebookEntry> {
.to_string_lossy()
.to_string();
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
let children = scan_dir_recursive(&path, vault_root);
let note_count = count_notes_in_dir(&path);
entries.push(NotebookEntry {
name,
path: path.to_string_lossy().to_string(),
relative_path: relative,
children,
note_count: child_note_count,
note_count,
});
}
entries
}
/// Combined scan: returns (children, note_count) in a single read_dir pass.
fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usize) {
let root = Path::new(vault_root);
let Ok(read_dir) = fs::read_dir(dir) else {
return (Vec::new(), 0);
};
let mut subdirs = Vec::new();
let mut note_count = 0usize;
for entry in read_dir.filter_map(|e| e.ok()) {
let ft = match entry.file_type() {
Ok(ft) => ft,
Err(_) => continue,
};
if ft.is_dir() && !is_hidden(&entry.path()) {
subdirs.push(entry);
} else if ft.is_file() && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
note_count += 1;
}
}
subdirs.sort_by(|a, b| {
a.file_name()
.to_string_lossy()
.to_lowercase()
.cmp(&b.file_name().to_string_lossy().to_lowercase())
});
let mut entries = Vec::new();
for dir_entry in subdirs {
let path = dir_entry.path();
let name = dir_entry.file_name().to_string_lossy().to_string();
let relative = path
.strip_prefix(root)
.unwrap_or(&path)
.to_string_lossy()
.to_string();
let (children, child_note_count) = scan_dir_with_count(&path, vault_root);
entries.push(NotebookEntry {
name,
path: path.to_string_lossy().to_string(),
relative_path: relative,
children,
note_count: child_note_count,
});
}
(entries, note_count)
fn count_notes_in_dir(dir: &Path) -> usize {
fs::read_dir(dir)
.map(|rd| {
rd.filter_map(|e| e.ok())
.filter(|e| {
let path = e.path();
path.is_file() && path.extension().and_then(|x| x.to_str()) == Some("md")
})
.count()
})
.unwrap_or(0)
}
pub(crate) fn is_hidden(path: &Path) -> bool {
fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| {
n.starts_with('.')
|| matches!(
n,
"_res" | "_resources" | "_attachments" | "_assets" | "assets" | "node_modules"
)
})
.map(|n| n.starts_with('.'))
.unwrap_or(false)
}
pub fn count_root_notes(vault_path: &str) -> Result<usize, String> {
let root = Path::new(vault_path);
if !root.exists() {
return Err("Vault path does not exist".to_string());
}
let count = fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().map(|ft| ft.is_file()).unwrap_or(false)
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
.count();
Ok(count)
}
pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<NoteEntry>, String> {
let scan_path = notebook_path.unwrap_or(vault_path);
let root = Path::new(scan_path);
let vault_root = Path::new(vault_path);
log::info!("scan_notes: vault={}, scan={}, exists={}", vault_path, scan_path, root.exists());
if !root.exists() {
return Err("Path does not exist".to_string());
}
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)]
{
let mut notes = Vec::new();
// Log what read_dir sees
if let Ok(rd) = fs::read_dir(root) {
let items: Vec<_> = rd.filter_map(|e| e.ok()).collect();
log::info!("scan_notes: read_dir found {} entries in {:?}", items.len(), root);
for (i, entry) in items.iter().enumerate().take(5) {
let ft = entry.file_type().map(|ft| if ft.is_file() { "file" } else if ft.is_dir() { "dir" } else { "other" }).unwrap_or("err");
log::info!(" [{}] {:?} type={}", i, entry.file_name(), ft);
}
} else {
log::info!("scan_notes: read_dir FAILED for {:?}", root);
}
if notebook_path.is_some() {
if let Ok(rd) = fs::read_dir(root) {
for entry in rd.filter_map(|e| e.ok()) {
let is_file = entry.file_type().map(|ft| ft.is_file()).unwrap_or(false);
if is_file && entry.path().extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(note) = read_note_entry_metadata_only(&entry.path(), vault_root) {
notes.push(note);
}
}
}
}
} else {
let hn_dir = helixnotes_dir(vault_path);
for entry in WalkDir::new(root)
.into_iter()
// filter_entry skips descending into hidden dirs (unlike filter)
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
.filter(|e| {
e.file_type().is_file()
&& e.path().extension().and_then(|x| x.to_str()) == Some("md")
})
{
if let Ok(note) = read_note_entry_metadata_only(entry.path(), vault_root) {
notes.push(note);
}
}
}
log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
#[cfg(desktop)]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
// Only direct children for a specific notebook
fs::read_dir(root)
.map_err(|e| e.to_string())?
.filter_map(|e| e.ok())
@@ -253,15 +136,17 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
} else {
// Recursive for "All Notes"
WalkDir::new(root)
.into_iter()
.filter_entry(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.filter_map(|e| e.ok())
.filter(|e| !is_hidden(e.path()) && !e.path().starts_with(&helixnotes_dir(vault_path)))
.map(|e| e.path().to_path_buf())
.filter(|p| p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md"))
.collect()
};
// Process files in parallel with partial reads
let mut notes: Vec<NoteEntry> = md_files
.par_iter()
.filter_map(|path| read_note_entry_fast(path, vault_root).ok())
@@ -269,7 +154,6 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
Ok(notes)
}
}
fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
@@ -277,51 +161,6 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
read_note_entry_from_str(&raw, path, vault_root)
}
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text.
#[cfg(mobile)]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
let mut buf = vec![0u8; 2048];
let bytes_read = file.read(&mut buf).map_err(|e| e.to_string())?;
buf.truncate(bytes_read);
let raw = String::from_utf8_lossy(&buf);
let filename = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_string();
let (mut meta, content) = frontmatter::parse_note(&raw, &filename);
// Always use filesystem timestamps on Android (faster than parsing date strings)
if let Ok(fs_meta) = fs::metadata(path) {
if let Ok(m) = fs_meta.modified() {
meta.modified = m.into();
}
if let Ok(c) = fs_meta.created() {
meta.created = c.into();
}
}
let relative = path
.strip_prefix(vault_root)
.unwrap_or(path)
.to_string_lossy()
.to_string();
let preview = frontmatter::extract_preview(&content, 120);
Ok(NoteEntry {
path: path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview,
})
}
/// Fast version: reads only the first ~2KB of the file (enough for frontmatter + preview).
fn read_note_entry_fast(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
@@ -345,21 +184,15 @@ fn read_note_entry_from_str(
let (mut meta, content) = frontmatter::parse_note(raw, &filename);
// Use filesystem times only if frontmatter didn't have created/modified fields
let has_created = raw.contains("\ncreated:") || raw.starts_with("created:");
let has_modified = raw.contains("\nmodified:") || raw.starts_with("modified:");
// Use filesystem times if frontmatter didn't have them
if let Ok(fs_meta) = fs::metadata(path) {
if !has_modified {
if let Ok(modified) = fs_meta.modified() {
meta.modified = modified.into();
}
}
if !has_created {
if let Ok(created) = fs_meta.created() {
meta.created = created.into();
}
}
}
let relative = path
.strip_prefix(vault_root)
@@ -400,19 +233,7 @@ pub fn save_note(path: &str, meta: &NoteMeta, body: &str) -> Result<(), String>
let mut updated_meta = meta.clone();
updated_meta.modified = Utc::now();
// Generate UUID on first save if note didn't have one
if updated_meta.id.is_empty() {
updated_meta.id = Uuid::new_v4().to_string();
}
// Read existing file to preserve unknown frontmatter fields
let existing = fs::read_to_string(path).unwrap_or_default();
let raw = if existing.is_empty() {
frontmatter::update_note_raw(&updated_meta, body)
} else {
frontmatter::merge_frontmatter(&existing, &updated_meta, body)
};
let raw = frontmatter::update_note_raw(&updated_meta, body);
fs::write(path, raw).map_err(|e| e.to_string())?;
Ok(())
}
@@ -469,117 +290,6 @@ pub fn create_note(
})
}
fn get_system_locale() -> Locale {
let sys = sys_locale::get_locale().unwrap_or_else(|| "en-US".to_string());
let lang = sys.split(&['-', '_', '.'][..]).next().unwrap_or("en");
match lang {
"af" => Locale::af_ZA,
"ar" => Locale::ar_SA,
"be" => Locale::be_BY,
"bg" => Locale::bg_BG,
"ca" => Locale::ca_ES,
"cs" => Locale::cs_CZ,
"da" => Locale::da_DK,
"de" => Locale::de_DE,
"el" => Locale::el_GR,
"es" => Locale::es_ES,
"et" => Locale::et_EE,
"fi" => Locale::fi_FI,
"fr" => Locale::fr_FR,
"he" => Locale::he_IL,
"hi" => Locale::hi_IN,
"hr" => Locale::hr_HR,
"hu" => Locale::hu_HU,
"id" => Locale::id_ID,
"is" => Locale::is_IS,
"it" => Locale::it_IT,
"ja" => Locale::ja_JP,
"ka" => Locale::ka_GE,
"ko" => Locale::ko_KR,
"lt" => Locale::lt_LT,
"lv" => Locale::lv_LV,
"mk" => Locale::mk_MK,
"nb" | "no" => Locale::nb_NO,
"nl" => Locale::nl_NL,
"nn" => Locale::nn_NO,
"pl" => Locale::pl_PL,
"pt" => Locale::pt_BR,
"ro" => Locale::ro_RO,
"ru" => Locale::ru_RU,
"sk" => Locale::sk_SK,
"sl" => Locale::sl_SI,
"sq" => Locale::sq_AL,
"sr" => Locale::sr_RS,
"sv" => Locale::sv_SE,
"th" => Locale::th_TH,
"tr" => Locale::tr_TR,
"uk" => Locale::uk_UA,
"vi" => Locale::vi_VN,
"zh" => Locale::zh_CN,
_ => Locale::en_US,
}
}
pub fn create_daily_note(
vault_path: &str,
date: Option<&str>,
format: &str,
) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let title = match format {
"iso" => target_date.format("%Y-%m-%d").to_string(),
"long" => target_date.format("%B %-d, %Y").to_string(),
"us" => target_date.format("%m/%d/%Y").to_string(),
"eu" => target_date.format("%d/%m/%Y").to_string(),
_ => {
let locale = get_system_locale();
target_date.format_localized("%B %d, %Y", locale).to_string()
}
};
let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
let file_path = dir.join(format!("{}.md", date_str));
let vault_root = Path::new(vault_path);
// If today's note already exists, return it
if file_path.exists() {
return read_note_entry(&file_path, vault_root);
}
let now = Utc::now();
let meta = NoteMeta {
id: Uuid::new_v4().to_string(),
title,
tags: vec!["daily".to_string()],
pinned: false,
created: now,
modified: now,
};
let raw = frontmatter::update_note_raw(&meta, "\n");
fs::write(&file_path, raw).map_err(|e| e.to_string())?;
let relative = file_path
.strip_prefix(vault_root)
.unwrap_or(&file_path)
.to_string_lossy()
.to_string();
Ok(NoteEntry {
path: file_path.to_string_lossy().to_string(),
relative_path: relative,
meta,
preview: String::new(),
})
}
pub fn create_notebook(
vault_path: &str,
parent_relative: Option<&str>,
@@ -655,26 +365,17 @@ 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, vault_path: &str) -> Result<String, String> {
pub fn rename_note(path: &str, new_title: &str) -> Result<String, String> {
let src = Path::new(path);
if !src.exists() {
return Err("Note does not exist".to_string());
}
// Read old title before renaming
// Update frontmatter
let raw = fs::read_to_string(src).map_err(|e| e.to_string())?;
let filename = src
.file_name()
@@ -682,16 +383,9 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
.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() {
meta.id = Uuid::new_v4().to_string();
}
let updated = frontmatter::merge_frontmatter(&raw, &meta, &content);
let updated = frontmatter::update_note_raw(&meta, &content);
// Rename file
let new_filename = sanitize_filename(new_title);
@@ -702,134 +396,7 @@ pub fn rename_note(path: &str, new_title: &str, vault_path: &str) -> Result<Stri
fs::rename(src, &new_path).map_err(|e| e.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);
}
}
Ok(new_path.to_string_lossy().to_string())
}
pub fn rename_notebook(path: &str, new_name: &str) -> Result<String, String> {
@@ -865,61 +432,14 @@ pub fn move_note(note_path: &str, dest_notebook: &str) -> Result<String, String>
Ok(dest.to_string_lossy().to_string())
}
pub fn move_notebook(notebook_path: &str, dest_parent: &str) -> Result<String, String> {
let src = Path::new(notebook_path);
if !src.exists() || !src.is_dir() {
return Err("Notebook does not exist".to_string());
}
let dest_parent_path = Path::new(dest_parent);
if !dest_parent_path.is_dir() {
return Err("Destination does not exist".to_string());
}
// No-op if already in that parent
if src.parent() == Some(dest_parent_path) {
return Err("Notebook is already in that location".to_string());
}
let dir_name = src.file_name().unwrap_or_default();
let dest = dest_parent_path.join(dir_name);
// Prevent moving into itself or a descendant
if dest.starts_with(src) {
return Err("Cannot move a notebook into itself or its descendants".to_string());
}
if dest.exists() {
return Err("A notebook with that name already exists in the destination".to_string());
}
fs::rename(src, &dest).map_err(|e| e.to_string())?;
Ok(dest.to_string_lossy().to_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> {
pub fn get_trash_notes(vault_path: &str) -> Result<Vec<NoteEntry>, String> {
let trash_dir = helixnotes_dir(vault_path).join("trash");
if !trash_dir.exists() {
return Ok(TrashContents { notes: Vec::new(), notebooks: Vec::new() });
return Ok(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())?;
@@ -928,38 +448,11 @@ pub fn get_trash_contents(vault_path: &str) -> Result<TrashContents, 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));
notebooks.sort_by(|a, b| b.modified.cmp(&a.modified));
Ok(TrashContents { notes, notebooks })
Ok(notes)
}
pub fn restore_note(
@@ -987,66 +480,18 @@ 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 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> {
pub fn permanent_delete(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(())
}
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.1",
"version": "1.0.1",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
@@ -24,7 +24,7 @@
}
],
"security": {
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost imgproxy: http://imgproxy.localhost https: blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; frame-src 'self' asset: http://asset.localhost",
"csp": "default-src 'self' 'unsafe-inline' 'unsafe-eval'; img-src 'self' asset: http://asset.localhost blob: data:; font-src 'self' data:; style-src 'self' 'unsafe-inline'",
"assetProtocol": {
"enable": true,
"scope": ["**/*", "/**", "**/.helixnotes/**"]

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