Compare commits

...
80 Commits
Author SHA1 Message Date
Yuri Karamian 922bcee1e2 Fix math/secret modal overflowing on mobile 2026-06-16 20:14:16 +02:00
Yuri Karamian d33bb8d96e Fix secret block overflowing on Android 2026-06-16 19:54:57 +02:00
Yuri Karamian 93ebb1987e Fix secret block overflowing on Android 2026-06-16 19:48:11 +02:00
Yuri Karamian cc0134dd22 Fix secret block overflowing on Android 2026-06-16 19:43:37 +02:00
Yuri Karamian 1164a6bd86 Fix wiki-link mark bleeding past ]] closure 2026-06-16 19:27:03 +02:00
blake 6fcf7bdb5e extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist
extra functionality for [[ ]]. Gives the option for the user to create the note if it doesn't exist.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/117
2026-06-16 15:09:08 +02:00
MF 8454f2eca8 Wrap selected text with typed pairs (#119)
Adds modular selection-pair handling for the rich editor and source mode so typing (, {, [, ', " or ` around a selection wraps it instead of replacing it.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/119
2026-06-16 14:59:14 +02:00
MF 7e480ad7e2 Keep zoom anchored in the editor (#118)
This keeps the visible note area stable when changing editor font size. It anchors the rich-text viewport before the font-size update and restores it after layout settles, so zooming shows the text getting larger or smaller without losing your place.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/118
2026-06-16 14:58:54 +02:00
MF bc7c985489 Add encrypted secret blocks (#111)
Adds /secret markdown blocks that store passphrase-protected secrets as helix-secret fences and unlock in the editor. Uses WebCrypto PBKDF2-HMAC-SHA256 with 600k iterations plus AES-256-GCM with salt, nonce, and AAD-bound metadata because it is dependency-free, portable to simple JS/Python decryptors, and supported by browser/webview WebCrypto. Tradeoff: PBKDF2 is not memory-hard like Argon2id/scrypt, so protection depends on strong passphrases and the high iteration count.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/111
2026-06-15 18:47:42 +02:00
MF a94c33edaf Scroll Past End (#116)
Adds editor scroll room after the final line so users can focus the active writing area without inserting blank lines. Ctrl+End moves to the document end and scrolls into that workspace; plain End keeps native line-end behavior. Presentation-only except cursor movement; does not mutate note content, mark dirty, or affect autosave.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/116
2026-06-15 18:47:05 +02:00
MF a1404062c5 Fix collapsible section title focus (#112)
Fixes keyboard navigation after creating a collapsible section: pressing Enter or Tab from the section title now moves the editor selection into the section body (`Start writing...`) instead of leaving the cursor in the title.

Validation: `COREPACK_ENABLE_AUTO_PIN=0 pnpm build`.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/112
2026-06-15 18:46:44 +02:00
MiladandFS 9bb55afccc Add editor zoom shortcuts (#108)
Adds Ctrl/Cmd +/-/0 and Ctrl/Cmd scroll shortcuts that change only markdown editor font size.

Co-authored-by: FS <failuresmith@proton.me>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/108
2026-06-15 18:46:17 +02:00
blake 173e25dadc Add a copy button for code snippets (#107)
WIP: Copy button for code snippets
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/107
2026-06-12 22:31:59 +02:00
Yuri Karamian 2fa9458eab Trash: empty/restore/delete now reflects immediately instead of needing an app restart (don't serve the volatile trash view from cache) 2026-06-12 17:57:48 +02:00
Yuri Karamian 0a3b7a13e3 Restore 8 themes dropped by the #106 merge (blueberry, forest-green, gruvbox, midnight-tide, cherry-blossom, synthwave, ember, moonlit); re-add their app.css blocks 2026-06-12 17:25:17 +02:00
blake 50d27d5636 main (#106)
WIP: Themes, accents, dropdown
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/106
2026-06-12 17:19:14 +02:00
Yuri Karamian d050ac907f Bundle Lora, Merriweather, Literata & Open Sans (self-hosted woff2 + OFL) so the editor serif/sans fonts render correctly on Windows (#100) 2026-06-12 14:45:03 +02:00
Yuri Karamian 80ca411a08 WebDAV sync: match remote paths encoding-agnostically so Infomaniak kDrive (and other providers that vary percent-encoding case) sync instead of wiping local notes (#102) 2026-06-12 14:42:09 +02:00
Yuri Karamian 35894cdfd1 Android: editor font-size setting now applies - disable WebView text-autosizing (layoutAlgorithm NORMAL) + respect the font-size var (#100) 2026-06-12 10:24:14 +02:00
Yuri Karamian 4df96362f4 Android: disable WebView text-size-adjust font-boosting so the editor font-size setting applies (#100) 2026-06-12 09:36:56 +02:00
Yuri Karamian fc7d34612d Bump version to 1.3.1 2026-06-12 09:22:12 +02:00
Yuri Karamian 1b7dc77792 Fix note breadcrumb showing 'Unfiled Notes' on Windows: normalize backslash paths before deriving the note's folder (#99) 2026-06-12 09:11:26 +02:00
blake 5f311f2404 Add 8 dark themes and 5 accent presets (#101) (#101)
WIP: New themes, new accents.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/101
2026-06-12 09:00:07 +02:00
Yuri Karamian f19c3c2caa Editor (#100): wrap long lines in Markdown source mode (no-wrap kept when line numbers are on) 2026-06-11 23:59:27 +02:00
Yuri Karamian 0f7b1debbb Editor: respect the font-size setting on Android (was hardcoded 16px); viewport already blocks auto-zoom so the hardcode was
unneeded (#100)
2026-06-11 23:45:32 +02:00
Yuri Karamian 4aa399e96e Fix desktop showing the mobile layout: decide layout from the actual build platform (cfg!(mobile)) instead of sniffing the webview user-agent, which some WebKitGTK builds report mobile-looking (#63) 2026-06-11 23:35:24 +02:00
Yuri Karamian 15c6e6388a Attachments: manual 'Find orphaned attachments' tool in the Info panel - scans all notes incl. trash, previews unreferenced files with sizes, moves to vault trash (recoverable); conservative raw+decoded matching, re-verified before moving, with a loading spinner (#52) 2026-06-11 23:16:59 +02:00
Yuri Karamian b501d2bd5c Notebooks: add a collapse-all / expand-all button to the sidebar tree header (#26) 2026-06-11 22:56:54 +02:00
Yuri Karamian fb4c5748a8 AI: OpenAI-compatible provider now uses /v1/chat/completions (not the legacy /v1/completions) so Nvidia NIM and similar work; tolerate a trailing /v1 in the base URL (#96) 2026-06-11 22:36:03 +02:00
Yuri Karamian d9cb615f1f Tasks: add a free-text filter box (matches task text or note title) (#95) 2026-06-11 22:25:51 +02:00
Yuri Karamian 0e09d1f9f1 Notebooks: fix drag-to-reorder and drag-out-to-root by deciding the drop action from the drop coordinates instead of cleared dragover state (#97) 2026-06-11 22:07:41 +02:00
Yuri Karamian db924f7352 Disable in-app updater for repo builds (HELIXNOTES_INSTALL_TYPE), open tel:/sms: links in the system handler (#98), label AI button \"OpenAI Compatible\" 2026-06-11 21:24:42 +02:00
Yuri Karamian 2fea44bde6 Vault picker: remove a vault from the Recent list (#93); fix Tasks view showing "All Notes" as the mobile header title 2026-06-11 20:48:20 +02:00
Yuri Karamian 784248f984 Tasks: per-vault persistence for the Flagged filter, Hide done, and sort mode (issue #94) 2026-06-11 15:50:18 +02:00
ArkHost ee16ef07c5 Merge pull request 'Update README.md - Add Solus install instructions' (#92) from clintre/HelixNotes:main into main
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/92
2026-06-11 09:12:03 +02:00
clintre 3648a2dc90 Update README.md - Add Solus install 2026-06-11 01:41:47 +02:00
Yuri Karamian 8f250d1ab9 Update README.md 2026-06-10 22:44:27 +02:00
Yuri Karamian 67d5ae87c3 Daily note title format setting (Localized/ISO/Long/US/EU); filenames stay ISO (#40) 2026-06-10 20:44:29 +02:00
Yuri Karamian 08abbe25ef update README 2026-06-10 18:03:50 +02:00
Yuri Karamian 000d68a0e7 Fix drag & drop on macOS 2026-06-10 10:22:31 +02:00
Yuri Karamian d8c391a480 v1.3.0 2026-06-10 09:25:18 +02:00
Yuri Karamian 459af56564 Edit note tags from within the editor: tag icon by pin/star opens an add/remove popover with autocomplete, and the metadata tag line is clickable (#60) 2026-06-08 22:07:23 +02:00
Yuri Karamian 9d82f2aed7 tidy AppConfig types, md-it shims, dead CSS 2026-06-08 21:21:54 +02:00
Yuri Karamian 4fb99c7e39 Fix top-of-note collapsible section re-opening on note switch (persist its folded state) issue #86 2026-06-08 21:11:35 +02:00
Yuri Karamian 92e8422cb0 Add 'Start week on' setting (Monday default / Sunday) for daily and tasks calendars 2026-06-08 20:56:28 +02:00
Yuri Karamian 5defddb35c Add tag autocomplete: suggest existing tags while typing to avoid near-duplicates 2026-06-08 12:51:03 +02:00
David Hain dc19976568 Update the Nix Flake cargoHash (#85)
To make the Nix build work again.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/85
2026-06-08 10:50:39 +02:00
Yuri Karamian bd59f6dbee Enhance calendars: unify daily and Tasks calendars, remember List/Calendar choice per vault 2026-06-08 02:53:34 +02:00
Yuri Karamian 26fa22afaa Add task management: aggregated Tasks view (list + calendar) with inline due/priority, check off, set from row, and drag-to-reschedule (#62) 2026-06-08 02:34:19 +02:00
Yuri Karamian e2702bdd51 Create new notes in the open note's notebook when not in a notebook view 2026-06-08 01:20:21 +02:00
Yuri Karamian 0d0ecee06e Add 'Restore last session on launch' setting (reopen last note + view) 2026-06-08 01:12:32 +02:00
Yuri Karamian 6115ad4ab6 Add 'Show dates' setting to toggle the note-list date 2026-06-08 01:00:25 +02:00
Yuri Karamian a400eeefed Store search index in local app-data, not the synced vault (fixes OneDrive churn) 2026-06-08 00:52:45 +02:00
Yuri Karamian f19ddee17d Persist note-list sort mode across restarts; date follows sort + tooltip 2026-06-08 00:47:05 +02:00
Yuri Karamian 393505e1ac Note list date follows sort mode + tooltip 2026-06-08 00:42:36 +02:00
Yuri Karamian b87e8b8a38 Treat Unfiled Notes as root when creating a new notebook 2026-06-08 00:32:43 +02:00
Yuri Karamian 5b62302b3a Refresh notebook note counts immediately after creating a note 2026-06-08 00:31:55 +02:00
Yuri Karamian ee0ffdf121 Allow creating a top-level notebook while a notebook is selected (clearable parent chip) 2026-06-08 00:26:39 +02:00
Yuri Karamian 89eea8e71b Add color swatch previews and /color insert 2026-06-08 00:04:38 +02:00
Yuri Karamian f27512c7ee Enhance Sync settings section 2026-06-07 23:41:20 +02:00
Yuri Karamian eafd2b0a24 Add WebDAV sync provider (manual + auto-sync, title-bar button) 2026-06-07 23:25:47 +02:00
Yuri Karamian aeafd55491 Add WebDAV sync provider (manual + auto-sync, top-bar button) 2026-06-07 23:14:04 +02:00
Yuri Karamian ccc5e3b7c6 Fix doubled safe-area inset causing top/bottom gap on Android 2026-06-07 19:11:59 +02:00
Yuri Karamian a28e514ed4 Fix Windows wiki-link/graph showing duplicate and old-version notes (path separators) 2026-06-07 18:55:12 +02:00
Yuri Karamian 7c94370e48 Add CJK (Chinese/Japanese/Korean) tokenizer for full-text search 2026-06-07 17:44:05 +02:00
Yuri Karamian ab39fc2ed6 Update Anthropic/OpenAI model lists (Opus 4.8, GPT-5.4/5.5) 2026-06-07 17:23:11 +02:00
Yuri Karamian 621e2b31e0 Add date/time insertion (slash, toolbar, context menu; ISO format) 2026-06-07 16:18:43 +02:00
Yuri Karamian ca3900815b Allow toggling task-list checkboxes in View Mode 2026-06-07 12:54:13 +02:00
Yuri Karamian b36ce9ed5b Fix code auto-linkify (#80) and currency text parsed as math (#81) 2026-06-06 14:36:52 +02:00
Yuri Karamian 59677577ec Fix unreliable window resize on Linux with custom resize handles (#77) 2026-06-05 18:27:08 +02:00
Yuri Karamian 71a0b9c0a0 Show mobile AI button for all configured providers 2026-06-05 11:21:11 +02:00
qyrhalandmidhun kumar 3876b7db96 Fix issue of ai features not appearing when configured (#78) (#79)
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/79
2026-06-05 11:17:56 +02:00
blake 0436136533 Added Solarized, Github, Nord, Tokyo Night, Dracula, and Catppuccin themes. (#76)
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/76
2026-06-04 21:49:49 +02:00
Yuri Karamian 139ecc8176 Add Interface Scale setting to zoom the whole app (80-200%) 2026-06-04 19:27:00 +02:00
qyrhalandmidhun kumar 4b82d51f6c Added openai compatible v1 completions and ollama api key compatibility (#73) (#74)
Added:
    - v1 completions (OpenAI compatible) and testing connection
    - Add API key handling for Ollama and v1 completions if required
Co-authored-by: midhun kumar <midh0001@student.monash.edu>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/74
2026-06-03 18:49:11 +02:00
Yuri Karamian 8ceb44df78 Remove unused fastlane metadata 2026-06-03 14:43:46 +02:00
Yuri Karamian 3b3e7fbbe6 Merge branch 'main' of ssh://codeberg.org/ArkHost/HelixNotes 2026-06-03 14:23:29 +02:00
Yuri Karamian 7cbd112585 Cross-platform groundwork: platform detection, iOS config, Windows drag-drop fix, Android 10 storage fix (#72), bundled fonts 2026-06-03 14:11:10 +02:00
doceys d275b0e783 Build on NixOS (#49)
feat: add Nix flake for NixOS/darwin builds (#49)
2026-04-30 11:31:47 +02:00
Yuri Karamian fedbdfd2a1 update README.md 2026-04-27 23:23:40 +02:00
119 changed files with 8562 additions and 711 deletions
+17 -1
View File
@@ -1,5 +1,10 @@
node_modules
# Foreign lockfiles - this project uses pnpm (pnpm-lock.yaml is the only lockfile)
package-lock.json
yarn.lock
bun.lockb
# Output
.output
.vercel
@@ -8,9 +13,20 @@ node_modules
/.svelte-kit
/build
# Nix
/result
# Tauri
/src-tauri/target
/src-tauri/gen
# Track Android source (MainActivity.kt, manifest, proguard, gradle); Tauri's nested
# gen/android/.gitignore + app/.gitignore keep build/, .gradle/, .cxx/, generated/, *.so out.
/src-tauri/gen/*
!/src-tauri/gen/android/
# Never commit signing secrets (nested ignore misses keystore.properties)
/src-tauri/gen/android/keystore.properties
/src-tauri/gen/android/key.properties
# OS
.DS_Store
+89 -16
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.8-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Latest Release](https://img.shields.io/badge/release-v1.3.0-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)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.2.8)
## Download (v1.3.0)
### Linux
@@ -26,25 +26,89 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
<details>
<summary>flake.nix</summary>
```nix
{
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
helix-notes = {
url = "git+https://codeberg.org/ArkHost/HelixNotes";
# inputs.nixpkgs.follows = "nixpkgs";
}
};
outputs = {
nixpkgs,
helix-notes,
...
}: let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
nixosConfigurations.default = nixpkgs.lib.nixosSystem {
system = system;
specialArgs = { inherit helix-notes; };
modules = [
/path/to/configuration.nix
];
};
};
}
```
</details>
<details>
<summary>configuration.nix</summary>
```nix
{
config,
lib,
pkgs,
helix-notes,
...
}:
{
users.users.<USERNAME> = {
packages = with pkgs; [
(helix-notes.packages.${pkgs.stdenv.hostPlatform.system}.default)
];
};
}
```
</details>
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.8/HelixNotes_1.2.8_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.0/HelixNotes_1.3.0_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_amd64.deb) (Ubuntu 22.04+)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_amd64.deb) (Ubuntu 22.04+)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64-setup.exe) (Windows 10/11)
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.8/HelixNotes_1.2.8_android.apk)
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_android.apk)
---
@@ -53,15 +117,23 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy)
- Outline panel, daily notes with calendar view, tags, drag-and-drop
- Math (KaTeX), PDF preview, Obsidian import
- AI writing tools (Ollama / Anthropic / OpenAI)
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
- Manual notebook sorting (drag to reorder above, into, or below)
- External `.md` viewer mode with import-to-vault flow
- PDF preview, Obsidian import, "Show in File Manager"
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
- Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode
- Themes, accent colors, fonts
- Local files, no cloud
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
- Local plain-text files, no company cloud
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -96,9 +168,10 @@ pnpm tauri build
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Settings](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph View](https://cdn.helixnotes.com/assets/screenshots/screenshot-3.png)
![AI Actions](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.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
@@ -1,3 +0,0 @@
- Internal note links ([[wiki-links]] open the linked note)
- Mobile UI improvements
- Window state persistence (desktop)
@@ -1,12 +0,0 @@
Markdown note-taking app built with Tauri and Rust. Your notes are stored as standard Markdown files on your device.
Features:
- Markdown editor with toolbar, slash commands, and code highlighting
- [[Wiki-links]] and graph view
- Full-text search
- Outline panel, daily notes, tags
- Math (KaTeX) and PDF preview
- AI writing tools (Ollama, Anthropic, OpenAI)
- Version history with diffs
- Obsidian import
- Themes, accent colors, fonts
@@ -1 +0,0 @@
Local markdown note-taking app. No cloud, no lock-in.
@@ -1 +0,0 @@
HelixNotes
Generated
+61
View File
@@ -0,0 +1,61 @@
{
"nodes": {
"flake-utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1777268161,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixos-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"root": {
"inputs": {
"flake-utils": "flake-utils",
"nixpkgs": "nixpkgs"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
}
},
"root": "root",
"version": 7
}
+76
View File
@@ -0,0 +1,76 @@
{
description = "HelixNotes (local Markdown notes app) with Nix flake";
inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
flake-utils.url = "github:numtide/flake-utils";
};
outputs = {
self,
nixpkgs,
flake-utils,
}:
flake-utils.lib.eachDefaultSystem (system: let
pkgs = import nixpkgs {inherit system;};
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name;
version = cargoToml.package.version;
in {
packages.default = pkgs.rustPlatform.buildRustPackage {
inherit pname version;
src = ./.;
cargoHash = "sha256-QF4HMYBjrxCBiq6PcsNy+kEOq/j+3zDzlcT5dy7MtQk=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version;
src = ./.;
fetcherVersion = 3;
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
};
nativeBuildInputs = with pkgs; [
cargo-tauri.hook
pnpmConfigHook
pnpm
nodejs
pkg-config
jq
moreutils
wrapGAppsHook3
];
buildInputs = with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
];
cargoRoot = "src-tauri";
buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
# Deactivate the upstream update mechanism
postPatch = ''
jq '
.bundle.createUpdaterArtifacts = false |
.plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
' \
src-tauri/tauri.conf.json | sponge src-tauri/tauri.conf.json
'';
meta = with pkgs.lib; {
description = "HelixNotes desktop application";
homepage = "https://helixnotes.com/";
license = licenses.agpl3Plus;
platforms = platforms.linux ++ platforms.darwin;
mainProgram = "helixnotes";
};
};
formatter = pkgs.alejandra;
});
}
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.2.9",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite dev",
+21 -3
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.2.9"
version = "1.3.1"
dependencies = [
"arboard",
"chrono",
@@ -1861,6 +1861,7 @@ dependencies = [
"log",
"notify",
"png 0.17.16",
"quick-xml 0.36.2",
"rayon",
"regex",
"reqwest 0.12.28",
@@ -1868,6 +1869,7 @@ dependencies = [
"serde",
"serde_json",
"serde_yaml",
"sha2",
"sys-locale",
"tantivy",
"tauri",
@@ -1880,6 +1882,7 @@ dependencies = [
"tauri-plugin-updater",
"tauri-plugin-window-state",
"tokio",
"urlencoding",
"uuid",
"walkdir",
"zip 2.4.2",
@@ -3418,7 +3421,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"quick-xml",
"quick-xml 0.38.4",
"serde",
"time",
]
@@ -3612,6 +3615,15 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.36.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe"
dependencies = [
"memchr",
]
[[package]]
name = "quick-xml"
version = "0.38.4"
@@ -5911,6 +5923,12 @@ dependencies = [
"serde_derive",
]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]]
name = "urlpattern"
version = "0.3.0"
@@ -6222,7 +6240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5423e94b6a63e68e439803a3e153a9252d5ead12fd853334e2ad33997e3889e3"
dependencies = [
"proc-macro2",
"quick-xml",
"quick-xml 0.38.4",
"quote",
]
+5 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.2.9"
version = "1.3.1"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -38,8 +38,11 @@ zip = { version = "2", default-features = false, features = ["deflate"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls", "blocking"] }
futures = "0.3"
rayon = "1"
quick-xml = "0.36"
urlencoding = "2"
sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(not(target_os = "android"))'.dependencies]
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2"
tauri-plugin-window-state = "2"
+1
View File
@@ -7,6 +7,7 @@
"core:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-start-resize-dragging",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"core:window:allow-close",
+2 -1
View File
@@ -6,6 +6,7 @@
"windows": ["main"],
"permissions": [
"updater:default",
"window-state:default"
"window-state:default",
"core:webview:allow-set-webview-zoom"
]
}
+12
View File
@@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false
+19
View File
@@ -0,0 +1,19 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
build
/captures
.externalNativeBuild
.cxx
local.properties
key.properties
/.tauri
/tauri.settings.gradle
+6
View File
@@ -0,0 +1,6 @@
/src/main/java/com/helixnotes/app/generated
/src/main/jniLibs/**/*.so
/src/main/assets/tauri.conf.json
/tauri.build.gradle.kts
/proguard-tauri.pro
/tauri.properties
@@ -0,0 +1,92 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
val keystoreProperties = Properties().apply {
val propFile = rootProject.file("keystore.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
compileSdk = 36
namespace = "com.helixnotes.app"
if (keystoreProperties.containsKey("storeFile")) {
signingConfigs {
create("release") {
storeFile = file(keystoreProperties["storeFile"] as String)
storePassword = keystoreProperties["storePassword"] as String
keyAlias = keystoreProperties["keyAlias"] as String
keyPassword = keystoreProperties["keyPassword"] as String
}
}
}
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.helixnotes.app"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
if (signingConfigs.names.contains("release")) {
signingConfig = signingConfigs.getByName("release")
}
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
+22
View File
@@ -0,0 +1,22 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# Preserve JS interface annotations
-keepattributes JavascriptInterface
# Keep our custom WebView JS bridge and all its public methods
-keep class com.helixnotes.app.StorageBridge {
public *;
}
# Keep MainActivity public methods (called from StorageBridge)
-keep class com.helixnotes.app.MainActivity {
public *;
}
# Ignore missing java.beans classes (referenced by Jackson but not available on Android)
-dontwarn java.beans.**
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:theme="@style/Theme.helixnotes"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -0,0 +1,157 @@
package com.helixnotes.app
import android.Manifest
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.Settings
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.MimeTypeMap
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import java.io.File
class MainActivity : TauriActivity() {
private var webView: WebView? = null
companion object {
private const val STORAGE_PERMISSION_REQUEST_CODE = 1001
}
override fun onWebViewCreate(webView: WebView) {
super.onWebViewCreate(webView)
this.webView = webView
webView.addJavascriptInterface(StorageBridge(this), "Android")
// Render text at the size CSS specifies, ignoring the device's system font-size
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
webView.settings.textZoom = 100
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
}
override fun onResume() {
super.onResume()
if (hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun hasStoragePermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ (API 30+): "All files access" special permission.
Environment.isExternalStorageManager()
} else {
// Android 7-10 (API 24-29): legacy runtime storage permission.
ContextCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) ==
PackageManager.PERMISSION_GRANTED
}
}
fun requestStoragePermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) return
try {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
intent.data = Uri.parse("package:$packageName")
startActivity(intent)
} catch (e: Exception) {
try {
val fallback = Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION)
startActivity(fallback)
} catch (_: Exception) {
val details = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS)
details.data = Uri.parse("package:$packageName")
startActivity(details)
}
}
} else {
// Android 7-10: request the legacy runtime permission directly.
ActivityCompat.requestPermissions(
this,
arrayOf(
Manifest.permission.READ_EXTERNAL_STORAGE,
Manifest.permission.WRITE_EXTERNAL_STORAGE
),
STORAGE_PERMISSION_REQUEST_CODE
)
}
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == STORAGE_PERMISSION_REQUEST_CODE && hasStoragePermission()) {
webView?.evaluateJavascript(
"window.__storagePermissionGranted && window.__storagePermissionGranted()",
null
)
}
}
fun prepareVaultDir(path: String): Boolean {
return try {
val dir = File(path)
dir.mkdirs()
File(dir, ".helixnotes/trash").mkdirs()
File(dir, ".helixnotes/attachments").mkdirs()
dir.exists() && dir.canWrite()
} catch (e: Exception) {
false
}
}
fun openFile(path: String) {
try {
val file = File(path)
if (!file.exists()) return
val uri = FileProvider.getUriForFile(this, "${packageName}.fileprovider", file)
val ext = MimeTypeMap.getFileExtensionFromUrl(path)
val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: "*/*"
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
} catch (e: Exception) {
android.util.Log.e("HelixNotes", "Failed to open file: $path", e)
}
}
}
class StorageBridge(private val activity: MainActivity) {
@JavascriptInterface
fun hasStoragePermission(): Boolean {
return activity.hasStoragePermission()
}
@JavascriptInterface
fun requestStoragePermission() {
activity.runOnUiThread {
activity.requestStoragePermission()
}
}
@JavascriptInterface
fun prepareVaultDir(path: String): Boolean {
return activity.prepareVaultDir(path)
}
@JavascriptInterface
fun openFile(path: String) {
activity.runOnUiThread {
activity.openFile(path)
}
}
}
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#5B6ABF"
android:pathData="M0,0h108v108h-108z" />
</vector>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".MainActivity">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Hello World!"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.8 KiB

@@ -0,0 +1,8 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (dark mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#1a1b26</item>
<item name="android:navigationBarColor">#1a1b26</item>
<item name="android:statusBarColor">#1a1b26</item>
</style>
</resources>
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>
@@ -0,0 +1,4 @@
<resources>
<string name="app_name">HelixNotes</string>
<string name="main_activity_title">HelixNotes</string>
</resources>
@@ -0,0 +1,9 @@
<resources xmlns:tools="http://schemas.android.com/tools">
<!-- Base application theme (light mode). -->
<style name="Theme.helixnotes" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<item name="android:windowBackground">#ffffff</item>
<item name="android:navigationBarColor">#ffffff</item>
<item name="android:statusBarColor">#ffffff</item>
<item name="android:windowLightStatusBar">true</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<external-path name="my_images" path="." />
<cache-path name="my_cache_images" path="." />
</paths>
+22
View File
@@ -0,0 +1,22 @@
buildscript {
repositories {
google()
mavenCentral()
}
dependencies {
classpath("com.android.tools.build:gradle:8.11.0")
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.25")
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
tasks.register("clean").configure {
delete("build")
}
@@ -0,0 +1,23 @@
plugins {
`kotlin-dsl`
}
gradlePlugin {
plugins {
create("pluginsForCoolKids") {
id = "rust"
implementationClass = "RustPlugin"
}
}
}
repositories {
google()
mavenCentral()
}
dependencies {
compileOnly(gradleApi())
implementation("com.android.tools.build:gradle:8.11.0")
}
@@ -0,0 +1,68 @@
import java.io.File
import org.apache.tools.ant.taskdefs.condition.Os
import org.gradle.api.DefaultTask
import org.gradle.api.GradleException
import org.gradle.api.logging.LogLevel
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.TaskAction
open class BuildTask : DefaultTask() {
@Input
var rootDirRel: String? = null
@Input
var target: String? = null
@Input
var release: Boolean? = null
@TaskAction
fun assemble() {
val executable = """pnpm""";
try {
runTauriCli(executable)
} catch (e: Exception) {
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
// Try different Windows-specific extensions
val fallbacks = listOf(
"$executable.exe",
"$executable.cmd",
"$executable.bat",
)
var lastException: Exception = e
for (fallback in fallbacks) {
try {
runTauriCli(fallback)
return
} catch (fallbackException: Exception) {
lastException = fallbackException
}
}
throw lastException
} else {
throw e;
}
}
}
fun runTauriCli(executable: String) {
val rootDirRel = rootDirRel ?: throw GradleException("rootDirRel cannot be null")
val target = target ?: throw GradleException("target cannot be null")
val release = release ?: throw GradleException("release cannot be null")
val args = listOf("tauri", "android", "android-studio-script");
project.exec {
workingDir(File(project.projectDir, rootDirRel))
executable(executable)
args(args)
if (project.logger.isEnabled(LogLevel.DEBUG)) {
args("-vv")
} else if (project.logger.isEnabled(LogLevel.INFO)) {
args("-v")
}
if (release) {
args("--release")
}
args(listOf("--target", target))
}.assertNormalExitValue()
}
}
@@ -0,0 +1,85 @@
import com.android.build.api.dsl.ApplicationExtension
import org.gradle.api.DefaultTask
import org.gradle.api.Plugin
import org.gradle.api.Project
import org.gradle.kotlin.dsl.configure
import org.gradle.kotlin.dsl.get
const val TASK_GROUP = "rust"
open class Config {
lateinit var rootDirRel: String
}
open class RustPlugin : Plugin<Project> {
private lateinit var config: Config
override fun apply(project: Project) = with(project) {
config = extensions.create("rust", Config::class.java)
val defaultAbiList = listOf("arm64-v8a", "armeabi-v7a", "x86", "x86_64");
val abiList = (findProperty("abiList") as? String)?.split(',') ?: defaultAbiList
val defaultArchList = listOf("arm64", "arm", "x86", "x86_64");
val archList = (findProperty("archList") as? String)?.split(',') ?: defaultArchList
val targetsList = (findProperty("targetList") as? String)?.split(',') ?: listOf("aarch64", "armv7", "i686", "x86_64")
extensions.configure<ApplicationExtension> {
@Suppress("UnstableApiUsage")
flavorDimensions.add("abi")
productFlavors {
create("universal") {
dimension = "abi"
ndk {
abiFilters += abiList
}
}
defaultArchList.forEachIndexed { index, arch ->
create(arch) {
dimension = "abi"
ndk {
abiFilters.add(defaultAbiList[index])
}
}
}
}
}
afterEvaluate {
for (profile in listOf("debug", "release")) {
val profileCapitalized = profile.replaceFirstChar { it.uppercase() }
val buildTask = tasks.maybeCreate(
"rustBuildUniversal$profileCapitalized",
DefaultTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for all targets"
}
tasks["mergeUniversal${profileCapitalized}JniLibFolders"].dependsOn(buildTask)
for (targetPair in targetsList.withIndex()) {
val targetName = targetPair.value
val targetArch = archList[targetPair.index]
val targetArchCapitalized = targetArch.replaceFirstChar { it.uppercase() }
val targetBuildTask = project.tasks.maybeCreate(
"rustBuild$targetArchCapitalized$profileCapitalized",
BuildTask::class.java
).apply {
group = TASK_GROUP
description = "Build dynamic library in $profile mode for $targetArch"
rootDirRel = config.rootDirRel
target = targetName
release = profile == "release"
}
buildTask.dependsOn(targetBuildTask)
tasks["merge$targetArchCapitalized${profileCapitalized}JniLibFolders"].dependsOn(
targetBuildTask
)
}
}
}
}
}
+24
View File
@@ -0,0 +1,24 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. More details, visit
# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app"s APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
android.nonFinalResIds=false
Binary file not shown.
@@ -0,0 +1,6 @@
#Tue May 10 19:22:52 CST 2022
distributionBase=GRADLE_USER_HOME
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionPath=wrapper/dists
zipStorePath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
+185
View File
@@ -0,0 +1,185 @@
#!/usr/bin/env sh
#
# Copyright 2015 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
##
## Gradle start up script for UN*X
##
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
PRG="$0"
# Need this for relative symlinks.
while [ -h "$PRG" ] ; do
ls=`ls -ld "$PRG"`
link=`expr "$ls" : '.*-> \(.*\)$'`
if expr "$link" : '/.*' > /dev/null; then
PRG="$link"
else
PRG=`dirname "$PRG"`"/$link"
fi
done
SAVED="`pwd`"
cd "`dirname \"$PRG\"`/" >/dev/null
APP_HOME="`pwd -P`"
cd "$SAVED" >/dev/null
APP_NAME="Gradle"
APP_BASE_NAME=`basename "$0"`
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD="maximum"
warn () {
echo "$*"
}
die () {
echo
echo "$*"
echo
exit 1
}
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "`uname`" in
CYGWIN* )
cygwin=true
;;
Darwin* )
darwin=true
;;
MINGW* )
msys=true
;;
NONSTOP* )
nonstop=true
;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
else
JAVACMD="$JAVA_HOME/bin/java"
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD="java"
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
# Increase the maximum file descriptors if we can.
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
MAX_FD_LIMIT=`ulimit -H -n`
if [ $? -eq 0 ] ; then
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
MAX_FD="$MAX_FD_LIMIT"
fi
ulimit -n $MAX_FD
if [ $? -ne 0 ] ; then
warn "Could not set maximum file descriptor limit: $MAX_FD"
fi
else
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
fi
fi
# For Darwin, add options to specify how the application appears in the dock
if $darwin; then
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
JAVACMD=`cygpath --unix "$JAVACMD"`
# We build the pattern for arguments to be converted via cygpath
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
SEP=""
for dir in $ROOTDIRSRAW ; do
ROOTDIRS="$ROOTDIRS$SEP$dir"
SEP="|"
done
OURCYGPATTERN="(^($ROOTDIRS))"
# Add a user-defined pattern to the cygpath arguments
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
fi
# Now convert the arguments - kludge to limit ourselves to /bin/sh
i=0
for arg in "$@" ; do
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
else
eval `echo args$i`="\"$arg\""
fi
i=`expr $i + 1`
done
case $i in
0) set -- ;;
1) set -- "$args0" ;;
2) set -- "$args0" "$args1" ;;
3) set -- "$args0" "$args1" "$args2" ;;
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
esac
fi
# Escape application args
save () {
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
echo " "
}
APP_ARGS=`save "$@"`
# Collect all arguments for the java command, following the shell quoting and substitution rules
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
exec "$JAVACMD" "$@"
+89
View File
@@ -0,0 +1,89 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%" == "" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%" == "" set DIRNAME=.
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if "%ERRORLEVEL%" == "0" goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if "%ERRORLEVEL%"=="0" goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
exit /b 1
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+3
View File
@@ -0,0 +1,3 @@
include ':app'
apply from: 'tauri.settings.gradle'
+38 -2
View File
@@ -21,6 +21,8 @@ pub fn ai_request(
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().unwrap();
rt.block_on(async {
// Handle all API keys as optional; ollama and v1 completions doesnt always require it.
let key_opt = if api_key.is_empty() { None } else { Some(api_key.as_str()) };
let result = match provider.as_str() {
"openai" => {
stream_openai(
@@ -40,7 +42,7 @@ pub fn ai_request(
stream_openai(
&app,
&url,
None,
key_opt,
&model,
&system_prompt,
&user_message,
@@ -48,6 +50,24 @@ pub fn ai_request(
)
.await
}
"openai_compatible" => {
let url = base_url.as_deref().unwrap_or("");
if url.is_empty() {
Err("No base URL configured for OpenAI Compatible provider".to_string())
} else {
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
stream_openai(
&app,
&url,
key_opt,
&model,
&system_prompt,
&user_message,
&request_id,
)
.await
}
}
_ => {
stream_anthropic(
&app,
@@ -74,6 +94,13 @@ pub fn ai_request(
});
}
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
}
async fn stream_anthropic(
app: &AppHandle,
api_key: &str,
@@ -353,12 +380,21 @@ pub async fn test_connection(
model: &str,
base_url: Option<&str>,
) -> Result<String, String> {
let key_opt = if api_key.is_empty() { None } else { Some(api_key) };
match provider {
"openai" => test_openai(OPENAI_API_URL, Some(api_key), model).await,
"ollama" => {
let url = base_url.unwrap_or(OLLAMA_DEFAULT_URL);
let url = format!("{}/v1/chat/completions", url.trim_end_matches('/'));
test_openai(&url, None, model).await
test_openai(&url, key_opt, model).await
}
"openai_compatible" => {
let url = base_url.unwrap_or("");
if url.is_empty() {
return Err("No base URL configured for OpenAI Compatible provider".to_string());
}
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
test_openai(&url, key_opt, model).await
}
_ => test_anthropic(api_key, model).await,
}
+584 -39
View File
@@ -11,18 +11,18 @@ use tauri::{AppHandle, Manager, State};
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
operations::ensure_vault_structure(&path)?;
// Initialize search index - rebuild in background on Android (FUSE is slow)
// Initialize search index - rebuild in background on mobile (sandboxed FS is slow)
let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(target_os = "android")]
#[cfg(mobile)]
{
let search_bg = search.clone();
let vault = path.clone();
std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault);
log::info!("Android: search index rebuild complete");
log::info!("mobile: search index rebuild complete");
});
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
search.rebuild(&path)?;
}
@@ -51,6 +51,17 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
Ok(())
}
#[tauri::command]
pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.vaults.retain(|v| v.path != path);
if config.active_vault.as_deref() == Some(path.as_str()) {
config.active_vault = None;
}
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -103,6 +114,14 @@ pub fn set_line_height(state: State<'_, AppState>, height: f64) -> Result<(), St
Ok(())
}
#[tauri::command]
pub fn set_ui_scale(state: State<'_, AppState>, scale: f64) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.ui_scale = Some(scale);
save_app_config(&config)?;
Ok(())
}
// ── Notebooks ──
#[tauri::command]
@@ -292,7 +311,7 @@ pub fn create_note(
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path, date.as_deref())?;
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
@@ -379,10 +398,14 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let mut entries = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths, which leaked .helixnotes/history snapshots in as dupes).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
@@ -390,9 +413,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
continue;
}
let path_str = path.to_string_lossy();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/") {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
@@ -441,15 +461,17 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
let mut seen_paths: HashSet<String> = HashSet::new();
let mut contents: Vec<String> = Vec::new();
let hn_dir = operations::helixnotes_dir(vault_path);
for entry in walkdir::WalkDir::new(vault)
.into_iter()
// Cross-platform exclusion (string "/.helixnotes/" checks miss Windows
// backslash paths; is_hidden also covers .stversions/.stfolder/.trash/.git).
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() { continue; }
let path_str = path.to_string_lossy().to_string();
if path_str.contains("/.helixnotes/") || path_str.contains("/.trash/")
|| path_str.contains("/.stversions/") || path_str.contains("/.stfolder") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip Syncthing conflict files
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
@@ -475,7 +497,8 @@ pub fn get_graph_data(state: State<'_, AppState>) -> Result<crate::types::GraphD
// Also index by vault-relative path without extension (e.g. "subfolder/note name")
if let Ok(rel) = path.strip_prefix(vault) {
let rel_no_ext = rel.with_extension("");
let rel_lower = rel_no_ext.to_string_lossy().to_lowercase();
// Normalize Windows backslashes so [[folder/note]] links resolve cross-platform.
let rel_lower = rel_no_ext.to_string_lossy().replace('\\', "/").to_lowercase();
relpath_to_idx.entry(rel_lower).or_insert(idx);
}
@@ -577,6 +600,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
None
}
// ── Tasks ──
#[tauri::command]
pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let task_re = regex::Regex::new(r"^\s*[-*]\s\[([ xX])\]\s+(.+?)\s*$").unwrap();
let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap();
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap();
let mut tasks = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => continue,
};
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let note_path = path.to_string_lossy().to_string();
for (i, line) in body.lines().enumerate() {
let caps = match task_re.captures(line) {
Some(c) => c,
None => continue,
};
let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open
let content = caps[2].to_string();
let due = due_re.captures(&content).map(|c| c[1].to_string());
let priority = prio_re.captures(&content).map(|c| {
let p = c[1].to_lowercase();
if p == "medium" { "med".to_string() } else { p }
});
// Strip metadata tokens for the display text, then collapse whitespace.
let mut text = due_re.replace_all(&content, "").to_string();
text = prio_re.replace_all(&text, " ").to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
tasks.push(crate::types::TaskItem {
note_path: note_path.clone(),
note_title: meta.title.clone(),
line: i,
raw_line: line.to_string(),
text,
completed,
due,
priority,
});
}
}
Ok(tasks)
}
fn toggle_checkbox_line(line: &str, done: bool) -> String {
let mut s = line.to_string();
if done {
if let Some(pos) = s.find("[ ]") {
s.replace_range(pos..pos + 3, "[x]");
}
} else {
for marker in ["[x]", "[X]"] {
if let Some(pos) = s.find(marker) {
s.replace_range(pos..pos + 3, "[ ]");
break;
}
}
}
s
}
#[tauri::command]
pub fn set_task_done(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
done: bool,
) -> Result<(), String> {
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
// Verify the expected line; if the note drifted, fall back to the first exact match.
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let toggled = toggle_checkbox_line(&lines[idx], done);
if toggled == lines[idx] {
return Ok(()); // already in the desired state
}
lines[idx] = toggled;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_priority_on_line(line: &str, priority: Option<&str>) -> String {
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(?:high|medium|med|low)\b").unwrap();
let stripped = prio_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match priority {
Some(p) => format!("{} !{}", stripped, p),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_priority(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
priority: Option<String>,
) -> Result<(), String> {
// Normalize/validate priority ("medium" -> "med"); None clears it.
let prio = match priority.as_deref() {
None | Some("") | Some("none") => None,
Some("high") => Some("high"),
Some("med") | Some("medium") => Some("med"),
Some("low") => Some("low"),
Some(_) => return Err("Invalid priority".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_priority_on_line(&lines[idx], prio);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_due_on_line(line: &str, due: Option<&str>) -> String {
let due_re = regex::Regex::new(r"(?:^|\s)due:\d{4}-\d{2}-\d{2}\b").unwrap();
let stripped = due_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match due {
Some(d) => format!("{} due:{}", stripped, d),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_due(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
due: Option<String>,
) -> Result<(), String> {
// Validate format (YYYY-MM-DD); None/empty clears the due date.
let date_re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
let due_val: Option<&str> = match due.as_deref() {
None | Some("") => None,
Some(d) if date_re.is_match(d) => Some(d),
Some(_) => return Err("Invalid due date".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_due_on_line(&lines[idx], due_val);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
// ── Search ──
#[tauri::command]
@@ -670,7 +941,7 @@ pub fn save_vault_state(state: State<'_, AppState>, vault_state: VaultState) ->
/// Read image from system clipboard (bypasses WebKitGTK clipboard bug).
/// Returns PNG bytes as Vec<u8>, or error if no image on clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
let mut clipboard =
@@ -695,14 +966,14 @@ pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Ok(buf)
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn read_clipboard_image() -> Result<Vec<u8>, String> {
Err("Clipboard image reading not supported on Android".to_string())
}
/// Copy an image file to the system clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
let data = std::fs::read(&path).map_err(|e| format!("Failed to read image: {}", e))?;
@@ -722,14 +993,14 @@ pub fn copy_image_to_clipboard(path: String) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn copy_image_to_clipboard(_path: String) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
}
/// Copy PNG bytes directly to the system clipboard.
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
#[tauri::command]
pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
let img = image::load_from_memory(&data)
@@ -748,7 +1019,7 @@ pub fn copy_png_to_clipboard(data: Vec<u8>) -> Result<(), String> {
Ok(())
}
#[cfg(target_os = "android")]
#[cfg(mobile)]
#[tauri::command]
pub fn copy_png_to_clipboard(_data: Vec<u8>) -> Result<(), String> {
Err("Clipboard image copy not supported on Android".to_string())
@@ -807,6 +1078,8 @@ pub fn set_general_settings(
state: State<'_, AppState>,
compact_notes: bool,
time_format: String,
week_start: String,
daily_title_format: String,
gpu_acceleration: bool,
autostart: bool,
pdf_preview: bool,
@@ -818,10 +1091,16 @@ pub fn set_general_settings(
show_tray_icon: bool,
close_to_tray: bool,
enable_wiki_links: bool,
show_note_dates: bool,
restore_last_session: bool,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.compact_notes = compact_notes;
config.show_note_dates = show_note_dates;
config.restore_last_session = restore_last_session;
config.time_format = time_format;
config.week_start = week_start;
config.daily_title_format = daily_title_format;
config.gpu_acceleration = gpu_acceleration;
config.autostart = autostart;
config.pdf_preview = pdf_preview;
@@ -1379,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
Ok(result.to_string_lossy().to_string())
}
// ── Orphaned attachment cleanup ──
#[derive(serde::Serialize)]
pub struct OrphanAttachment {
pub name: String,
pub size: u64,
}
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// and matches each filename against both the raw note text and a percent-decoded copy (so a
// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is
// KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
let attachments_dir = operations::helixnotes_dir(vault).join("attachments");
if !attachments_dir.is_dir() {
return Ok(Vec::new());
}
let mut files: Vec<(String, u64)> = Vec::new();
for entry in std::fs::read_dir(&attachments_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
if p.is_file() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
files.push((name, size));
}
}
if files.is_empty() {
return Ok(Vec::new());
}
let mut haystack = String::new();
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(p) {
haystack.push_str(&content);
haystack.push('\n');
}
}
}
let decoded = percent_decode(&haystack);
let orphans = files
.into_iter()
.filter(|(name, _)| !haystack.contains(name.as_str()) && !decoded.contains(name.as_str()))
.collect();
Ok(orphans)
}
#[tauri::command]
pub fn find_orphaned_attachments(
state: State<'_, AppState>,
) -> Result<Vec<OrphanAttachment>, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
let orphans = scan_orphaned_attachments(&vault)?;
Ok(orphans
.into_iter()
.map(|(name, size)| OrphanAttachment { name, size })
.collect())
}
#[tauri::command]
pub fn trash_orphaned_attachments(
state: State<'_, AppState>,
names: Vec<String>,
) -> Result<u32, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
// Re-scan now and only move files that are STILL orphaned (guards against a reference added
// between scan and confirm), intersected with the caller's selection.
let current: std::collections::HashSet<String> = scan_orphaned_attachments(&vault)?
.into_iter()
.map(|(n, _)| n)
.collect();
let attachments_dir = operations::helixnotes_dir(&vault).join("attachments");
let trash_dir = operations::helixnotes_dir(&vault).join("trash");
std::fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
let mut moved = 0u32;
for (i, name) in names.iter().enumerate() {
if !current.contains(name) {
continue;
}
// Path-traversal guard: only act on a bare filename inside the attachments dir.
if std::path::Path::new(name)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.as_deref()
!= Some(name.as_str())
{
continue;
}
let src = attachments_dir.join(name);
if !src.is_file() {
continue;
}
let dest = trash_dir.join(format!("{}_{}_attachment_{}", stamp, i, name));
if std::fs::rename(&src, &dest).is_ok() {
moved += 1;
}
}
Ok(moved)
}
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.bytes();
@@ -1461,8 +1849,12 @@ fn xdg_open(arg: &str) -> Result<(), String> {
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: open the URL/path without flashing a console window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
std::process::Command::new("cmd")
.args(["/C", "start", "", arg])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
@@ -1654,12 +2046,22 @@ pub fn set_ai_settings(
model: String,
writing_style: Option<String>,
base_url: Option<String>,
ollama_api_key: Option<String>,
openai_compatible_base_url: Option<String>,
openai_compatible_api_key: Option<String>,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
let key = api_key.filter(|k| !k.is_empty());
match provider.as_deref() {
Some("openai") => config.openai_api_key = key,
Some("ollama") => config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty()),
Some("ollama") => {
config.ollama_base_url = base_url.filter(|u| !u.trim().is_empty());
config.ollama_api_key = ollama_api_key.filter(|k| !k.is_empty());
}
Some("openai_compatible") => {
config.openai_compatible_base_url = openai_compatible_base_url.filter(|u| !u.trim().is_empty());
config.openai_compatible_api_key = openai_compatible_api_key.filter(|k| !k.is_empty());
}
_ => config.ai_api_key = key,
}
config.ai_provider = provider;
@@ -1678,17 +2080,18 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
.ai_provider
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" {
// Ollama doesn't require an API key
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone()
} else {
config.ai_api_key.clone()
let key = match provider.as_str() {
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
"openai" => config.openai_api_key.clone(),
_ => config.ai_api_key.clone(),
}
.ok_or("No API key configured")?;
let model = config.ai_model.clone();
let base_url = config.ollama_base_url.clone();
let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, base_url)
};
@@ -1719,6 +2122,128 @@ pub fn test_ai_connection(app: AppHandle) -> Result<(), String> {
Ok(())
}
// ── Sync (WebDAV) ──
fn sync_config_from(config: &AppConfig) -> Result<crate::sync::WebdavConfig, String> {
if config.sync_provider.as_deref() != Some("webdav") {
return Err("Sync is not configured".to_string());
}
let url = config
.webdav_url
.clone()
.filter(|u| !u.trim().is_empty())
.ok_or("WebDAV URL is not set")?;
Ok(crate::sync::WebdavConfig {
url,
username: config.webdav_username.clone().unwrap_or_default(),
password: config.webdav_password.clone().unwrap_or_default(),
})
}
#[tauri::command]
pub fn set_sync_settings(
state: State<'_, AppState>,
provider: Option<String>,
url: Option<String>,
username: Option<String>,
password: Option<String>,
sync_on_open: bool,
sync_on_change: bool,
sync_interval_minutes: u32,
) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.sync_provider = provider.filter(|p| !p.is_empty());
config.webdav_url = url.filter(|u| !u.trim().is_empty());
config.webdav_username = username.filter(|u| !u.is_empty());
config.webdav_password = password.filter(|p| !p.is_empty());
config.sync_on_open = sync_on_open;
config.sync_on_change = sync_on_change;
config.sync_interval_minutes = sync_interval_minutes;
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn test_sync_connection(app: AppHandle) -> Result<(), String> {
let cfg = {
let state = app.state::<AppState>();
let config = state.config.lock().map_err(|e| e.to_string())?;
sync_config_from(&config)?
};
std::thread::spawn(move || {
use tauri::Emitter;
match crate::sync::test_connection(cfg) {
Ok(msg) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": true, "message": msg }),
);
}
Err(e) => {
let _ = app.emit(
"sync-test-result",
serde_json::json!({ "success": false, "error": e }),
);
}
}
});
Ok(())
}
#[tauri::command]
pub fn sync_now(app: AppHandle) -> Result<(), String> {
use std::sync::atomic::Ordering;
// Guard against overlapping syncs (manual button + interval + on-change can collide).
if app.state::<AppState>().syncing.swap(true, Ordering::SeqCst) {
return Ok(()); // a sync is already running
}
let (vault, cfg) = {
let state = app.state::<AppState>();
let config = match state.config.lock() {
Ok(c) => c,
Err(e) => {
state.syncing.store(false, Ordering::SeqCst);
return Err(e.to_string());
}
};
let gathered = config
.active_vault
.clone()
.ok_or_else(|| "No active vault".to_string())
.and_then(|v| sync_config_from(&config).map(|c| (v, c)));
match gathered {
Ok(vc) => vc,
Err(e) => {
drop(config);
state.syncing.store(false, Ordering::SeqCst);
return Err(e);
}
}
};
std::thread::spawn(move || {
use tauri::Emitter;
let result = crate::sync::run_sync(app.clone(), vault, cfg);
app.state::<AppState>().syncing.store(false, Ordering::SeqCst);
match result {
Ok(summary) => {
let ts = chrono::Utc::now().to_rfc3339();
if let Ok(mut config) = app.state::<AppState>().config.lock() {
config.last_sync_time = Some(ts.clone());
let _ = save_app_config(&config);
}
let _ = app.emit(
"sync-done",
serde_json::json!({ "success": true, "summary": summary, "last_sync_time": ts }),
);
}
Err(e) => {
let _ = app.emit("sync-error", serde_json::json!({ "success": false, "error": e }));
}
}
});
Ok(())
}
#[tauri::command]
pub fn ai_ask(
app: AppHandle,
@@ -1734,17 +2259,19 @@ pub fn ai_ask(
.ai_provider
.clone()
.unwrap_or_else(|| "anthropic".to_string());
let key = if provider == "ollama" {
Some(String::new())
} else if provider == "openai" {
config.openai_api_key.clone()
} else {
config.ai_api_key.clone()
let key = match provider.as_str() {
"ollama" => Some(config.ollama_api_key.clone().unwrap_or_default()),
"openai_compatible" => Some(config.openai_compatible_api_key.clone().unwrap_or_default()),
"openai" => config.openai_api_key.clone(),
_ => config.ai_api_key.clone(),
}
.ok_or("No API key configured. Go to Settings > AI to set up your API key.")?;
let model = config.ai_model.clone();
let style = config.ai_writing_style.clone();
let base_url = config.ollama_base_url.clone();
let base_url = match provider.as_str() {
"openai_compatible" => config.openai_compatible_base_url.clone(),
_ => config.ollama_base_url.clone(),
};
(provider, key, model, style, base_url)
};
@@ -1798,17 +2325,21 @@ pub fn ai_ask(
// ── Helpers ──
static ANDROID_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
// On mobile (Android + iOS) the OS config dir is injected at startup via the Tauri
// path resolver, since dirs::config_dir() is not reliable in the app sandbox.
static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
pub fn set_android_config_dir(path: std::path::PathBuf) {
let _ = ANDROID_CONFIG_DIR.set(path);
pub fn set_mobile_config_dir(path: std::path::PathBuf) {
let _ = MOBILE_CONFIG_DIR.set(path);
}
fn app_config_path() -> Result<std::path::PathBuf, String> {
let app_dir = if let Some(config_dir) = dirs::config_dir() {
// Prefer the injected mobile dir when present (set only on mobile); fall back to
// the platform config dir on desktop.
let app_dir = if let Some(mobile_dir) = MOBILE_CONFIG_DIR.get() {
mobile_dir.join("helixnotes")
} else if let Some(config_dir) = dirs::config_dir() {
config_dir.join("helixnotes")
} else if let Some(android_dir) = ANDROID_CONFIG_DIR.get() {
android_dir.join("helixnotes")
} else {
return Err("Config directory not available yet".to_string());
};
@@ -1833,8 +2364,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
// ── Install Type Detection ──
#[tauri::command]
pub fn is_mobile_platform() -> bool {
// Compile-time platform: true only for the Android/iOS builds. Authoritative, unlike the
// webview user-agent, which some desktop WebKitGTK builds report mobile-looking (issue #63).
cfg!(mobile)
}
#[tauri::command]
pub fn get_install_type() -> String {
// Build-time override for distro packagers (e.g. Solus): build with
// HELIXNOTES_INSTALL_TYPE=solus to report that type and suppress the in-app updater.
if let Some(forced) = option_env!("HELIXNOTES_INSTALL_TYPE") {
if !forced.is_empty() {
return forced.to_string();
}
}
if cfg!(target_os = "macos") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
+31 -12
View File
@@ -4,6 +4,7 @@ mod commands;
mod history;
mod search;
mod state;
mod sync;
mod types;
mod vault;
@@ -11,7 +12,7 @@ use state::AppState;
#[allow(unused_imports)]
use tauri::{Emitter, Manager};
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
use tauri::{
image::Image,
menu::{MenuBuilder, MenuItemBuilder},
@@ -20,14 +21,20 @@ use tauri::{
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
// Work around blank window from WebKitGTK DMABUF/GBM allocation failures on some Linux GPUs.
#[cfg(target_os = "linux")]
if std::env::var_os("WEBKIT_DISABLE_DMABUF_RENDERER").is_none() {
std::env::set_var("WEBKIT_DISABLE_DMABUF_RENDERER", "1");
}
rustls::crypto::ring::default_provider()
.install_default()
.expect("Failed to install rustls crypto provider");
let config = commands::load_app_config();
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let show_tray = config.show_tray_icon;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let close_to_tray = config.close_to_tray && show_tray;
let app_state = AppState::new(config);
@@ -44,28 +51,28 @@ pub fn run() {
)?;
}
// On Android, set config dir from Tauri's path resolver, then reload config
#[cfg(target_os = "android")]
// On mobile, set config dir from Tauri's path resolver, then reload config
#[cfg(mobile)]
{
if let Ok(config_dir) = app.path().config_dir() {
commands::set_android_config_dir(config_dir);
commands::set_mobile_config_dir(config_dir);
} else if let Ok(data_dir) = app.path().data_dir() {
commands::set_android_config_dir(data_dir);
commands::set_mobile_config_dir(data_dir);
}
// Reload config now that the Android config dir is available
// Reload config now that the mobile config dir is available
let reloaded = commands::load_app_config();
let _ = app.state::<AppState>().config.lock().map(|mut cfg| {
*cfg = reloaded;
});
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
if show_tray {
setup_tray(app)?;
}
// Check CLI args for a .md file path on initial launch
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
let file_arg = std::env::args().skip(1).find(|a| {
let a = a.trim();
@@ -91,12 +98,14 @@ pub fn run() {
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::open_vault,
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_accent_color,
commands::set_font_size,
commands::set_font_family,
commands::set_line_height,
commands::set_ui_scale,
commands::get_notebooks,
commands::count_root_notes,
commands::create_notebook,
@@ -114,6 +123,10 @@ pub fn run() {
commands::get_all_tags,
commands::get_all_note_titles,
commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
commands::set_task_priority,
commands::set_task_due,
commands::search_notes,
commands::reindex,
commands::get_trash,
@@ -135,6 +148,8 @@ pub fn run() {
commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats,
commands::find_orphaned_attachments,
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::open_url,
@@ -152,7 +167,11 @@ pub fn run() {
commands::set_ai_settings,
commands::test_ai_connection,
commands::ai_ask,
commands::set_sync_settings,
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type,
commands::is_mobile_platform,
commands::get_pending_open_file,
])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
@@ -229,7 +248,7 @@ pub fn run() {
});
});
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
builder = builder.plugin(tauri_plugin_single_instance::init(|app, args, cwd| {
// Always show/focus the main window
@@ -290,7 +309,7 @@ pub fn run() {
.expect("error while running tauri application");
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
fn setup_tray(app: &mut tauri::App) -> Result<(), Box<dyn std::error::Error>> {
let show = MenuItemBuilder::with_id("show", "Show HelixNotes").build(app)?;
let quit = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
+239 -28
View File
@@ -5,15 +5,163 @@ use std::fs;
use std::path::Path;
use std::sync::Mutex;
use tantivy::collector::TopDocs;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
use tantivy::directory::MmapDirectory;
#[cfg(target_os = "android")]
#[cfg(mobile)]
use tantivy::directory::RamDirectory;
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query};
use tantivy::query::{BooleanQuery, FuzzyTermQuery, Occur, PhrasePrefixQuery, Query, TermQuery};
use tantivy::schema::*;
use tantivy::tokenizer::{LowerCaser, TextAnalyzer, Token, TokenStream, Tokenizer};
use tantivy::{Index, IndexWriter, TantivyDocument, Term};
use walkdir::WalkDir;
/// Bumped whenever the index schema or tokenizer changes, so the on-disk index is
/// wiped and rebuilt once on the next vault open (the index is derived from the
/// notes, so this never loses data).
const INDEX_SCHEMA_VERSION: &str = "2-cjk-bigram";
/// Per-vault search index dir in local app-data, kept out of the (possibly synced) vault.
#[cfg(desktop)]
fn vault_index_base(vault_path: &str) -> Option<std::path::PathBuf> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(vault_path.as_bytes());
let key: String = hasher.finalize()[..8].iter().map(|b| format!("{:02x}", b)).collect();
dirs::data_local_dir().map(|d| d.join("helixnotes").join("search").join(key))
}
/// True for characters in the CJK / Japanese / Korean blocks, which are written
/// without spaces between words. These get uni/bi-gram tokenized so substring
/// search works; everything else keeps the default word-splitting behaviour.
fn is_cjk(c: char) -> bool {
matches!(c as u32,
0x3400..=0x4DBF // CJK Unified Ideographs Extension A
| 0x4E00..=0x9FFF // CJK Unified Ideographs (Han)
| 0x3040..=0x309F // Hiragana
| 0x30A0..=0x30FF // Katakana
| 0xAC00..=0xD7AF // Hangul Syllables
| 0xF900..=0xFAFF // CJK Compatibility Ideographs
| 0xFF00..=0xFFEF // Halfwidth and Fullwidth Forms
)
}
/// Tokenize `text` so that CJK runs become overlapping unigrams + bigrams (recall
/// over precision: a query for any substring of a CJK run will match), while runs
/// of other alphanumeric characters become a single word token (same as tantivy's
/// SimpleTokenizer, so Latin/English indexing is unchanged). Lowercasing is applied
/// by a LowerCaser filter in the analyzer, not here.
fn cjk_tokens(text: &str) -> Vec<Token> {
let mut tokens: Vec<Token> = Vec::new();
let mut position: usize = 0;
let mut cjk_run: Vec<(usize, char)> = Vec::new();
let mut word = String::new();
let mut word_start: usize = 0;
macro_rules! flush_word {
() => {{
if !word.is_empty() {
let len = word.len();
tokens.push(Token {
offset_from: word_start,
offset_to: word_start + len,
position,
text: std::mem::take(&mut word),
position_length: 1,
});
position += 1;
}
}};
}
macro_rules! flush_cjk {
() => {{
let n = cjk_run.len();
for i in 0..n {
let (off, ch) = cjk_run[i];
tokens.push(Token {
offset_from: off,
offset_to: off + ch.len_utf8(),
position,
text: ch.to_string(),
position_length: 1,
});
position += 1;
}
for i in 0..n.saturating_sub(1) {
let (off1, c1) = cjk_run[i];
let (off2, c2) = cjk_run[i + 1];
let mut s = String::with_capacity(c1.len_utf8() + c2.len_utf8());
s.push(c1);
s.push(c2);
tokens.push(Token {
offset_from: off1,
offset_to: off2 + c2.len_utf8(),
position,
text: s,
position_length: 1,
});
position += 1;
}
cjk_run.clear();
}};
}
for (offset, c) in text.char_indices() {
if is_cjk(c) {
flush_word!();
cjk_run.push((offset, c));
} else if c.is_alphanumeric() {
flush_cjk!();
if word.is_empty() {
word_start = offset;
}
word.push(c);
} else {
flush_cjk!();
flush_word!();
}
}
flush_cjk!();
flush_word!();
let _ = position; // the final flush increments position but nothing reads it after
tokens
}
/// A pre-computed token stream (all tokens collected up front by `cjk_tokens`).
struct PreTokenizedStream {
tokens: Vec<Token>,
idx: usize,
}
impl TokenStream for PreTokenizedStream {
fn advance(&mut self) -> bool {
if self.idx < self.tokens.len() {
self.idx += 1;
true
} else {
false
}
}
fn token(&self) -> &Token {
&self.tokens[self.idx - 1]
}
fn token_mut(&mut self) -> &mut Token {
&mut self.tokens[self.idx - 1]
}
}
#[derive(Clone)]
struct CjkTokenizer;
impl Tokenizer for CjkTokenizer {
type TokenStream<'a> = PreTokenizedStream;
fn token_stream<'a>(&'a mut self, text: &'a str) -> PreTokenizedStream {
PreTokenizedStream {
tokens: cjk_tokens(text),
idx: 0,
}
}
}
pub struct SearchIndex {
index: Index,
writer: Mutex<Option<IndexWriter>>,
@@ -29,29 +177,70 @@ impl SearchIndex {
pub fn new(vault_path: &str) -> Result<Self, String> {
let mut schema_builder = Schema::builder();
let path_field = schema_builder.add_text_field("path", STRING | STORED);
let title_field = schema_builder.add_text_field("title", TEXT | STORED);
let body_field = schema_builder.add_text_field("body", TEXT);
let tags_field = schema_builder.add_text_field("tags", TEXT | STORED);
// CJK-aware tokenizer for the indexed text fields (keeps Latin/English
// behaviour identical; adds substring matching for Chinese/Japanese/Korean).
let cjk_indexing = TextFieldIndexing::default()
.set_tokenizer("cjk")
.set_index_option(IndexRecordOption::WithFreqsAndPositions);
let cjk_text = TextOptions::default().set_indexing_options(cjk_indexing.clone());
let cjk_text_stored = cjk_text.clone().set_stored();
let title_field = schema_builder.add_text_field("title", cjk_text_stored.clone());
let body_field = schema_builder.add_text_field("body", cjk_text);
let tags_field = schema_builder.add_text_field("tags", cjk_text_stored);
let schema = schema_builder.build();
// Android: use in-memory index (flock doesn't work on /storage/emulated FUSE filesystem)
// Mobile: use in-memory index (flock is unreliable on the sandboxed/FUSE filesystem)
// Desktop: use mmap directory for persistent index on disk
#[cfg(target_os = "android")]
#[cfg(mobile)]
let index = {
let dir = RamDirectory::create();
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
};
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let index = {
let index_dir = helixnotes_dir(vault_path).join("search_index");
let base = vault_index_base(vault_path)
.unwrap_or_else(|| helixnotes_dir(vault_path).join("search_index"));
let index_dir = base.join("index");
let version_path = base.join("version");
// Remove the old in-vault index so it stops syncing.
let hn = helixnotes_dir(vault_path);
let _ = fs::remove_dir_all(hn.join("search_index"));
let _ = fs::remove_file(hn.join("search_index.version"));
// One-time wipe when the schema/tokenizer version changes; rebuild() repopulates.
let version_ok = fs::read_to_string(&version_path)
.map(|v| v.trim() == INDEX_SCHEMA_VERSION)
.unwrap_or(false);
if !version_ok {
let _ = fs::remove_dir_all(&index_dir);
}
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
let idx = match Index::open_or_create(dir, schema.clone()) {
Ok(idx) => idx,
Err(_) => {
// Schema mismatch (older index) or corruption: wipe and recreate.
let _ = fs::remove_dir_all(&index_dir);
fs::create_dir_all(&index_dir).map_err(|e| e.to_string())?;
let dir = MmapDirectory::open(&index_dir).map_err(|e| e.to_string())?;
Index::open_or_create(dir, schema.clone()).map_err(|e| e.to_string())?
}
};
let _ = fs::write(&version_path, INDEX_SCHEMA_VERSION);
idx
};
#[cfg(target_os = "android")]
// Register the CJK-aware tokenizer (in-memory; must be done on every open,
// before the writer is created, so both indexing and querying use it).
index.tokenizers().register(
"cjk",
TextAnalyzer::builder(CjkTokenizer).filter(LowerCaser).build(),
);
#[cfg(mobile)]
let heap_size = 15_000_000;
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
let heap_size = 50_000_000;
let writer = index.writer(heap_size).map_err(|e| e.to_string())?;
@@ -153,28 +342,50 @@ impl SearchIndex {
let searcher = reader.searcher();
let fields = vec![self.title_field, self.body_field, self.tags_field];
let terms: Vec<String> = query_str
.split_whitespace()
.map(|t| t.to_lowercase())
.collect();
// Tokenize the query with the SAME CJK-aware analyzer used for indexing, so a
// Chinese/Japanese/Korean query becomes the same uni/bigram tokens as the docs.
// (For pure-ASCII queries this yields the same lowercased word tokens as before.)
let mut analyzer = self
.index
.tokenizers()
.get("cjk")
.ok_or("cjk tokenizer not registered")?;
let terms: Vec<String> = {
let mut out = Vec::new();
let mut stream = analyzer.token_stream(query_str);
while stream.advance() {
out.push(stream.token().text.clone());
}
out
};
// For each search term, create prefix + fuzzy queries across all fields (OR),
// then AND all terms together
// For each term, OR queries across all fields, then AND all terms together.
// CJK tokens (uni/bigrams) already encode segmentation -> exact term match.
// Latin tokens keep prefix + fuzzy matching (unchanged English behaviour).
let term_queries: Vec<(Occur, Box<dyn Query>)> = terms
.iter()
.map(|term| {
let is_cjk_term = term.chars().any(is_cjk);
let field_queries: Vec<(Occur, Box<dyn Query>)> = fields
.iter()
.flat_map(|&field| {
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)]
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));
+2
View File
@@ -10,6 +10,7 @@ pub struct AppState {
pub search_index: Mutex<Option<Arc<SearchIndex>>>,
pub watcher: Mutex<Option<RecommendedWatcher>>,
pub importing: AtomicBool,
pub syncing: AtomicBool,
pub pending_open_file: Mutex<Option<String>>,
}
@@ -20,6 +21,7 @@ impl AppState {
search_index: Mutex::new(None),
watcher: Mutex::new(None),
importing: AtomicBool::new(false),
syncing: AtomicBool::new(false),
pending_open_file: Mutex::new(None),
}
}
+710
View File
@@ -0,0 +1,710 @@
// WebDAV sync provider.
//
// Model: the vault stays local; this mirrors it to/from a user-configured WebDAV
// remote (Nextcloud/ownCloud/NAS). A local manifest (.helixnotes/sync_state.json)
// records what was in sync last time, so we can do a three-way diff (local vs remote
// vs manifest) and resolve each file as upload/download/delete, with keep-both
// conflict copies so nothing is ever lost.
//
// Synced set: every `*.md` in the vault tree, plus `.helixnotes/attachments/`.
// Everything else under `.helixnotes/` (search_index, trash, history, *.json, the
// manifest itself) is local-only and never synced.
use crate::state::AppState;
use crate::vault::operations::helixnotes_dir;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Path, PathBuf};
use tauri::{Emitter, Manager};
use walkdir::WalkDir;
// ───────────────────────── config ─────────────────────────
pub struct WebdavConfig {
pub url: String,
pub username: String,
pub password: String,
}
#[derive(Default, Clone, Serialize)]
pub struct SyncSummary {
pub uploaded: u32,
pub downloaded: u32,
pub deleted_local: u32,
pub deleted_remote: u32,
pub conflicts: u32,
}
// ───────────────────────── manifest ─────────────────────────
#[derive(Default, Serialize, Deserialize)]
struct SyncManifest {
files: HashMap<String, ManifestEntry>, // relpath (forward-slash) -> entry
}
#[derive(Clone, Serialize, Deserialize)]
struct ManifestEntry {
local_hash: String, // sha256 of local content at last sync
remote_etag: String, // remote etag at last sync
}
fn manifest_path(vault: &str) -> PathBuf {
helixnotes_dir(vault).join("sync_state.json")
}
fn load_manifest(vault: &str) -> SyncManifest {
fs::read_to_string(manifest_path(vault))
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
fn save_manifest(vault: &str, m: &SyncManifest) {
if let Ok(s) = serde_json::to_string(m) {
let _ = fs::write(manifest_path(vault), s);
}
}
// ───────────────────────── helpers ─────────────────────────
fn sha256_hex(bytes: &[u8]) -> String {
let mut h = Sha256::new();
h.update(bytes);
let digest = h.finalize();
let mut s = String::with_capacity(64);
for b in digest {
s.push_str(&format!("{:02x}", b));
}
s
}
fn normalize_etag(s: &str) -> String {
s.trim().trim_start_matches("W/").trim_matches('"').to_string()
}
/// Percent-encode each path segment, keeping the `/` separators.
fn encode_path(relpath: &str) -> String {
relpath
.split('/')
.map(|seg| urlencoding::encode(seg).into_owned())
.collect::<Vec<_>>()
.join("/")
}
/// Build the conflict-copy relpath: `dir/Note (conflict <stamp>).md`.
fn conflict_relpath(relpath: &str, stamp: &str) -> String {
let (dir, name) = match relpath.rfind('/') {
Some(i) => (&relpath[..=i], &relpath[i + 1..]),
None => ("", relpath),
};
let (stem, ext) = match name.rfind('.') {
Some(i) => (&name[..i], &name[i..]),
None => (name, ""),
};
format!("{}{} (conflict {}){}", dir, stem, stamp, ext)
}
fn write_local(vault: &Path, relpath: &str, bytes: &[u8]) -> Result<(), String> {
let target = vault.join(relpath);
if let Some(parent) = target.parent() {
fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
fs::write(&target, bytes).map_err(|e| e.to_string())
}
struct LocalFile {
hash: String,
path: PathBuf,
}
/// The synced set: `*.md` anywhere outside `.helixnotes/`, plus everything under
/// `.helixnotes/attachments/`. Applied to BOTH local and remote so pointing at a
/// folder with unrelated files never drags them into the vault.
fn is_synced_relpath(rel: &str) -> bool {
if rel.starts_with(".helixnotes/") {
rel.starts_with(".helixnotes/attachments/")
} else {
rel.ends_with(".md")
}
}
fn scan_local(vault: &str) -> Result<HashMap<String, LocalFile>, String> {
let vault_path = Path::new(vault);
let mut map = HashMap::new();
for entry in WalkDir::new(vault_path).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if !p.is_file() {
continue;
}
let rel = match p.strip_prefix(vault_path) {
Ok(r) => r.to_string_lossy().replace('\\', "/"),
Err(_) => continue,
};
if !is_synced_relpath(&rel) {
continue;
}
let bytes = match fs::read(p) {
Ok(b) => b,
Err(_) => continue,
};
map.insert(
rel,
LocalFile {
hash: sha256_hex(&bytes),
path: p.to_path_buf(),
},
);
}
Ok(map)
}
// ───────────────────────── WebDAV client ─────────────────────────
struct RemoteEntry {
relpath: String,
etag: String,
is_dir: bool,
}
struct WebdavClient {
base: String, // normalized, no trailing slash
base_path: String, // path component of base, no trailing slash (percent-encoded)
client: reqwest::blocking::Client,
user: String,
pass: String,
}
impl WebdavClient {
fn new(cfg: WebdavConfig) -> Result<Self, String> {
let base = cfg.url.trim().trim_end_matches('/').to_string();
if !base.starts_with("http://") && !base.starts_with("https://") {
return Err("WebDAV URL must start with http:// or https://".to_string());
}
let base_url = reqwest::Url::parse(&base).map_err(|e| format!("Invalid URL: {e}"))?;
let base_path = base_url.path().trim_end_matches('/').to_string();
let client = reqwest::blocking::Client::builder()
.timeout(std::time::Duration::from_secs(60))
.build()
.map_err(|e| e.to_string())?;
Ok(Self {
base,
base_path,
client,
user: cfg.username,
pass: cfg.password,
})
}
fn url_for(&self, relpath: &str) -> String {
if relpath.is_empty() {
self.base.clone()
} else {
format!("{}/{}", self.base, encode_path(relpath))
}
}
fn propfind(&self, relpath: &str, depth: &str) -> Result<Vec<RemoteEntry>, String> {
let method = reqwest::Method::from_bytes(b"PROPFIND").unwrap();
let body = r#"<?xml version="1.0" encoding="utf-8"?><d:propfind xmlns:d="DAV:"><d:prop><d:getetag/><d:resourcetype/></d:prop></d:propfind>"#;
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.header("Depth", depth)
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.map_err(|e| e.to_string())?;
let status = resp.status();
if status.as_u16() == 401 {
return Err("Authentication failed (check username / app password)".to_string());
}
if status.as_u16() == 404 {
return Err("WebDAV path not found (check the URL)".to_string());
}
if !status.is_success() {
return Err(format!("WebDAV PROPFIND failed: HTTP {}", status.as_u16()));
}
let text = resp.text().map_err(|e| e.to_string())?;
parse_multistatus(&text, &self.base_path)
}
/// Recursively list all files (relpath -> etag) by walking collections at Depth 1
/// (Depth: infinity is disabled on some servers, so we walk instead).
fn list(&self) -> Result<HashMap<String, String>, String> {
let mut files = HashMap::new();
let mut visited: HashSet<String> = HashSet::new();
let mut queue: Vec<String> = vec![String::new()];
while let Some(dir) = queue.pop() {
if !visited.insert(dir.clone()) {
continue;
}
for e in self.propfind(&dir, "1")? {
if e.relpath == dir {
continue; // the collection itself
}
if e.is_dir {
if !visited.contains(&e.relpath) {
queue.push(e.relpath);
}
} else {
files.insert(e.relpath, e.etag);
}
}
}
Ok(files)
}
fn get(&self, relpath: &str) -> Result<Vec<u8>, String> {
let resp = self
.client
.get(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("GET {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp.bytes().map_err(|e| e.to_string())?.to_vec())
}
fn put(&self, relpath: &str, bytes: &[u8]) -> Result<Option<String>, String> {
let resp = self
.client
.put(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.body(bytes.to_vec())
.send()
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("PUT {} failed: HTTP {}", relpath, resp.status().as_u16()));
}
Ok(resp
.headers()
.get("etag")
.and_then(|h| h.to_str().ok())
.map(normalize_etag))
}
fn delete(&self, relpath: &str) -> Result<(), String> {
let resp = self
.client
.delete(self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status();
if s.is_success() || s.as_u16() == 404 {
Ok(())
} else {
Err(format!("DELETE {} failed: HTTP {}", relpath, s.as_u16()))
}
}
fn mkcol(&self, relpath: &str) -> Result<(), String> {
let method = reqwest::Method::from_bytes(b"MKCOL").unwrap();
let resp = self
.client
.request(method, self.url_for(relpath))
.basic_auth(&self.user, Some(&self.pass))
.send()
.map_err(|e| e.to_string())?;
let s = resp.status().as_u16();
// 201 created; 405 already exists; 301 redirect-to-existing.
if resp.status().is_success() || s == 405 || s == 301 {
Ok(())
} else {
Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
}
}
/// Ensure every ancestor collection of `relpath` exists on the remote.
fn ensure_dirs(&self, relpath: &str, created: &mut HashSet<String>) -> Result<(), String> {
let parts: Vec<&str> = relpath.split('/').collect();
if parts.len() <= 1 {
return Ok(());
}
let mut cur = String::new();
for part in &parts[..parts.len() - 1] {
if !cur.is_empty() {
cur.push('/');
}
cur.push_str(part);
if created.insert(cur.clone()) {
self.mkcol(&cur)?;
}
}
Ok(())
}
fn put_with_dirs(
&self,
relpath: &str,
bytes: &[u8],
created: &mut HashSet<String>,
) -> Result<Option<String>, String> {
self.ensure_dirs(relpath, created)?;
self.put(relpath, bytes)
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash).
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
/// unmappable href makes that file look deleted on the remote, and the reconciler then
/// wipes the local copy. (issue #102)
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
// Pull out just the path component (some servers return absolute-URL hrefs).
let raw = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href)
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
.path()
.to_string()
} else {
href.to_string()
};
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
// both sides first makes the prefix match encoding-agnostic. (issue #102)
let path = urlencoding::decode(raw.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
.into_owned();
let base = urlencoding::decode(base_path.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
.into_owned();
let rest = match path.strip_prefix(&base) {
Some(r) => r.trim_start_matches('/'),
None => {
return Err(format!(
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
Aborting before deleting anything locally - please report this URL."
))
}
};
if rest.is_empty() {
return Ok(None); // the base collection itself
}
Ok(Some(rest.to_string()))
}
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
use quick_xml::events::Event;
use quick_xml::reader::Reader;
#[derive(PartialEq)]
enum Cap {
None,
Href,
Etag,
}
let mut reader = Reader::from_str(xml);
let mut entries = Vec::new();
let mut href: Option<String> = None;
let mut etag: Option<String> = None;
let mut is_dir = false;
let mut cap = Cap::None;
let mut buf = String::new();
loop {
match reader.read_event() {
Ok(Event::Start(e)) => match e.local_name().as_ref() {
b"response" => {
href = None;
etag = None;
is_dir = false;
}
b"href" => {
cap = Cap::Href;
buf.clear();
}
b"getetag" => {
cap = Cap::Etag;
buf.clear();
}
b"collection" => is_dir = true,
_ => {}
},
Ok(Event::Empty(e)) => {
if e.local_name().as_ref() == b"collection" {
is_dir = true;
}
}
Ok(Event::Text(e)) => {
if cap != Cap::None {
if let Ok(t) = e.unescape() {
buf.push_str(&t);
}
}
}
Ok(Event::End(e)) => match e.local_name().as_ref() {
b"href" => {
href = Some(buf.clone());
cap = Cap::None;
}
b"getetag" => {
etag = Some(buf.clone());
cap = Cap::None;
}
b"response" => {
if let Some(h) = href.take() {
if let Some(rel) = href_to_relpath(&h, base_path)? {
entries.push(RemoteEntry {
relpath: rel,
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
is_dir,
});
}
}
}
_ => {}
},
Ok(Event::Eof) => break,
Err(e) => return Err(format!("XML parse error: {e}")),
_ => {}
}
}
Ok(entries)
}
// ───────────────────────── sync engine ─────────────────────────
fn apply_changes(
client: &WebdavClient,
vault: &str,
local: &HashMap<String, LocalFile>,
remote: &HashMap<String, String>,
manifest: &SyncManifest,
) -> Result<(SyncManifest, SyncSummary), String> {
let vault_path = Path::new(vault);
let mut new_m = SyncManifest::default();
let mut sum = SyncSummary::default();
let mut created: HashSet<String> = HashSet::new();
let stamp = chrono::Local::now().format("%Y-%m-%d %H-%M-%S").to_string();
let mut keys: HashSet<&String> = HashSet::new();
keys.extend(local.keys());
keys.extend(remote.keys());
keys.extend(manifest.files.keys());
for key in keys {
let l = local.get(key);
let r = remote.get(key);
let m = manifest.files.get(key);
match (l, r) {
(Some(lf), Some(re)) => {
let local_changed = m.map_or(true, |me| me.local_hash != lf.hash);
let remote_changed = m.map_or(true, |me| &me.remote_etag != re);
if !local_changed && !remote_changed {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else if local_changed && !remote_changed {
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_else(|| re.clone()),
},
);
sum.uploaded += 1;
} else if !local_changed && remote_changed {
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// both changed: compare contents; identical => no real conflict.
let rbytes = client.get(key)?;
let rhash = sha256_hex(&rbytes);
if rhash == lf.hash {
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: re.clone(),
},
);
} else {
// keep-both: remote becomes canonical; local saved as a conflict copy.
let lbytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
write_local(vault_path, key, &rbytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: rhash,
remote_etag: re.clone(),
},
);
let ckey = conflict_relpath(key, &stamp);
write_local(vault_path, &ckey, &lbytes)?;
let cetag = client.put_with_dirs(&ckey, &lbytes, &mut created)?;
new_m.files.insert(
ckey,
ManifestEntry {
local_hash: sha256_hex(&lbytes),
remote_etag: cetag.unwrap_or_default(),
},
);
sum.conflicts += 1;
}
}
}
(Some(lf), None) => match m {
None => {
// new local -> upload
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
}
Some(me) => {
if me.local_hash != lf.hash {
// remote deleted but local edited -> resurrect (upload)
let bytes = fs::read(&lf.path).map_err(|e| e.to_string())?;
let etag = client.put_with_dirs(key, &bytes, &mut created)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: lf.hash.clone(),
remote_etag: etag.unwrap_or_default(),
},
);
sum.uploaded += 1;
} else {
// remote deleted, local unchanged -> delete local
let _ = fs::remove_file(&lf.path);
sum.deleted_local += 1;
}
}
},
(None, Some(re)) => match m {
None => {
// new remote -> download
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
}
Some(me) => {
if &me.remote_etag != re {
// local deleted but remote changed -> resurrect (download)
let bytes = client.get(key)?;
write_local(vault_path, key, &bytes)?;
new_m.files.insert(
key.clone(),
ManifestEntry {
local_hash: sha256_hex(&bytes),
remote_etag: re.clone(),
},
);
sum.downloaded += 1;
} else {
// local deleted, remote unchanged -> delete remote
client.delete(key)?;
sum.deleted_remote += 1;
}
}
},
(None, None) => {
// gone on both sides -> drop from manifest
}
}
}
Ok((new_m, sum))
}
/// Validate the connection (auth + URL) with a cheap PROPFIND.
pub fn test_connection(cfg: WebdavConfig) -> Result<String, String> {
let client = WebdavClient::new(cfg)?;
client.propfind("", "0")?;
Ok("Connection successful".to_string())
}
/// Run a full sync. Mutes the file watcher while applying local writes, then
/// rebuilds the search index. Returns a summary of what changed.
pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Result<SyncSummary, String> {
use std::sync::atomic::Ordering;
let state = app.state::<AppState>();
let client = WebdavClient::new(cfg)?;
let _ = app.emit("sync-progress", serde_json::json!({ "status": "listing" }));
let remote: HashMap<String, String> = client
.list()?
.into_iter()
.filter(|(k, _)| is_synced_relpath(k))
.collect();
let local = scan_local(&vault)?;
let manifest = load_manifest(&vault);
// Safety guard (issue #102): never treat an empty remote listing as "everything was
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
// map), not a real mass-deletion - so abort instead of wiping local notes.
if remote.is_empty() && !manifest.files.is_empty() {
return Err(
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
even though files were synced before, so no local files were deleted. This usually \
means the server reports paths in a format the app can't read yet - please report \
your WebDAV provider and folder URL."
.to_string(),
);
}
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
state.importing.store(true, Ordering::Relaxed);
let applied = apply_changes(&client, &vault, &local, &remote, &manifest);
state.importing.store(false, Ordering::Relaxed);
let (new_manifest, summary) = applied?;
save_manifest(&vault, &new_manifest);
// Re-index after files changed (search is non-critical; ignore failures).
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.rebuild(&vault);
}
}
// The watcher was muted during apply, so nudge the UI to refresh its lists.
let _ = app.emit(
"file-changed",
crate::types::FileEvent {
event_type: "modify".to_string(),
path: vault.clone(),
},
);
Ok(summary)
}
+93
View File
@@ -70,9 +70,17 @@ pub struct AppConfig {
#[serde(default)]
pub line_height: Option<f64>,
#[serde(default)]
pub ui_scale: Option<f64>,
#[serde(default)]
pub compact_notes: bool,
#[serde(default = "default_true")]
pub show_note_dates: bool,
#[serde(default)]
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default = "default_daily_title_format")]
pub daily_title_format: String,
#[serde(default)]
pub gpu_acceleration: bool,
#[serde(default)]
@@ -109,6 +117,12 @@ pub struct AppConfig {
pub openai_api_key: Option<String>,
#[serde(default)]
pub ollama_base_url: Option<String>,
#[serde(default)]
pub ollama_api_key: Option<String>,
#[serde(default)]
pub openai_compatible_base_url: Option<String>,
#[serde(default)]
pub openai_compatible_api_key: Option<String>,
#[serde(default = "default_ai_model")]
pub ai_model: String,
#[serde(default)]
@@ -121,6 +135,25 @@ pub struct AppConfig {
pub close_to_tray: bool,
#[serde(default = "default_true")]
pub enable_wiki_links: bool,
#[serde(default)]
pub restore_last_session: bool,
// WebDAV sync (opt-in; all off by default). Endpoint is fully user-configured.
#[serde(default)]
pub sync_provider: Option<String>,
#[serde(default)]
pub webdav_url: Option<String>,
#[serde(default)]
pub webdav_username: Option<String>,
#[serde(default)]
pub webdav_password: Option<String>,
#[serde(default)]
pub sync_on_open: bool,
#[serde(default)]
pub sync_on_change: bool,
#[serde(default)]
pub sync_interval_minutes: u32,
#[serde(default)]
pub last_sync_time: Option<String>,
}
fn default_true() -> bool {
@@ -143,6 +176,14 @@ 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
}
@@ -161,8 +202,12 @@ impl Default for AppConfig {
font_size: None,
font_family: None,
line_height: None,
ui_scale: None,
compact_notes: false,
show_note_dates: true,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
gpu_acceleration: true,
autostart: false,
pdf_preview: false,
@@ -181,12 +226,24 @@ impl Default for AppConfig {
ai_api_key: None,
openai_api_key: None,
ollama_base_url: None,
ollama_api_key: None,
openai_compatible_base_url: None,
openai_compatible_api_key: None,
ai_model: "claude-sonnet-4-6".to_string(),
ai_writing_style: None,
default_view_mode: false,
show_tray_icon: false,
close_to_tray: false,
enable_wiki_links: true,
restore_last_session: false,
sync_provider: None,
webdav_url: None,
webdav_username: None,
webdav_password: None,
sync_on_open: false,
sync_on_change: false,
sync_interval_minutes: 0,
last_sync_time: None,
}
}
}
@@ -203,6 +260,22 @@ pub struct VaultState {
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 {
@@ -215,6 +288,14 @@ impl Default for VaultState {
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(),
}
}
}
@@ -293,3 +374,15 @@ pub struct GraphEdge {
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>,
}
+36 -24
View File
@@ -154,7 +154,7 @@ fn scan_dir_with_count(dir: &Path, vault_root: &str) -> (Vec<NotebookEntry>, usi
(entries, note_count)
}
fn is_hidden(path: &Path) -> bool {
pub(crate) fn is_hidden(path: &Path) -> bool {
path.file_name()
.and_then(|n| n.to_str())
.map(|n| {
@@ -194,8 +194,8 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
return Err("Path does not exist".to_string());
}
// On Android, use metadata-only scan (no file reads) for fast listing on FUSE
#[cfg(target_os = "android")]
// On mobile, use metadata-only scan (no file reads) for fast listing on sandboxed FS
#[cfg(mobile)]
{
let mut notes = Vec::new();
// Log what read_dir sees
@@ -238,12 +238,12 @@ pub fn scan_notes(vault_path: &str, notebook_path: Option<&str>) -> Result<Vec<N
}
}
}
log::info!("scan_notes: Android scan found {} notes", notes.len());
log::info!("scan_notes: mobile scan found {} notes", notes.len());
notes.sort_by(|a, b| b.meta.modified.cmp(&a.meta.modified));
return Ok(notes);
}
#[cfg(not(target_os = "android"))]
#[cfg(desktop)]
{
let md_files: Vec<PathBuf> = if notebook_path.is_some() {
fs::read_dir(root)
@@ -279,7 +279,7 @@ fn read_note_entry(path: &Path, vault_root: &Path) -> Result<NoteEntry, String>
/// Android-only: reads just the frontmatter (first 2KB) for tags/title/pinned,
/// uses filesystem timestamps for dates. No preview text.
#[cfg(target_os = "android")]
#[cfg(mobile)]
fn read_note_entry_metadata_only(path: &Path, vault_root: &Path) -> Result<NoteEntry, String> {
// Read first 2KB - enough for frontmatter with tags, title, pinned
let mut file = fs::File::open(path).map_err(|e| e.to_string())?;
@@ -520,15 +520,27 @@ fn get_system_locale() -> Locale {
}
}
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
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 locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).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())?;
@@ -708,17 +720,20 @@ fn update_wikilinks_after_rename(
new_title: &str,
) {
let vault = Path::new(vault_path);
let vault_prefix = format!("{}/", vault_path);
let hn_dir = helixnotes_dir(vault_path);
// Compute vault-relative path refs (without .md) for path-based wikilink refs
let old_rel_ref = old_path.strip_prefix(&vault_prefix)
.unwrap_or(old_path)
.strip_suffix(".md")
.unwrap_or(old_path);
let new_rel_ref = new_path.strip_prefix(&vault_prefix)
.unwrap_or(new_path)
.strip_suffix(".md")
.unwrap_or(new_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,
@@ -726,6 +741,7 @@ fn update_wikilinks_after_rename(
// 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();
@@ -735,10 +751,6 @@ fn update_wikilinks_after_rename(
if p.extension().and_then(|ext| ext.to_str()) != Some("md") {
return false;
}
let p_str = p.to_string_lossy();
if p_str.contains("/.helixnotes/") || p_str.contains("/.trash/") {
return false;
}
// Check if this note's filename (without .md) matches the old title
p.file_stem()
.and_then(|s| s.to_str())
@@ -748,12 +760,12 @@ fn update_wikilinks_after_rename(
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_str.contains("/.helixnotes/") || path_str.contains("/.trash/") { continue; }
if path.extension().and_then(|e| e.to_str()) != Some("md") { continue; }
// Skip the renamed note itself
if *path_str == *new_path { continue; }
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.2.9",
"version": "1.3.1",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"bundle": {
"iOS": {
"minimumSystemVersion": "14.0"
}
}
}
+1
View File
@@ -3,6 +3,7 @@
"windows": [
{
"label": "main",
"dragDropEnabled": false,
"decorations": true,
"titleBarStyle": "Overlay",
"hiddenTitle": true,
+18
View File
@@ -0,0 +1,18 @@
{
"app": {
"windows": [
{
"title": "HelixNotes",
"label": "main",
"width": 1200,
"height": 800,
"minWidth": 800,
"minHeight": 500,
"resizable": true,
"fullscreen": false,
"decorations": false,
"dragDropEnabled": false
}
]
}
}
+924 -188
View File
File diff suppressed because it is too large Load Diff
+96 -1
View File
@@ -13,12 +13,17 @@ import type {
ImportResult,
BackupEntry,
VersionEntry,
TaskItem,
} from "./types";
export async function openVault(path: string): Promise<void> {
return invoke("open_vault", { path });
}
export async function removeVault(path: string): Promise<void> {
return invoke("remove_vault", { path });
}
export async function getAppConfig(): Promise<AppConfig> {
return invoke("get_app_config");
}
@@ -43,6 +48,10 @@ export async function setLineHeight(height: number): Promise<void> {
return invoke("set_line_height", { height });
}
export async function setUiScale(scale: number): Promise<void> {
return invoke("set_ui_scale", { scale });
}
export async function getNotebooks(): Promise<NotebookEntry[]> {
return invoke("get_notebooks");
}
@@ -210,6 +219,8 @@ export async function setNotebookIcon(
export async function setGeneralSettings(
compactNotes: boolean,
timeFormat: string,
weekStart: string,
dailyTitleFormat: string,
gpuAcceleration: boolean,
autostart: boolean,
pdfPreview: boolean,
@@ -221,10 +232,14 @@ export async function setGeneralSettings(
showTrayIcon: boolean,
closeToTray: boolean,
enableWikiLinks: boolean,
showNoteDates: boolean,
restoreLastSession: boolean,
): Promise<void> {
return invoke("set_general_settings", {
compactNotes,
timeFormat,
weekStart,
dailyTitleFormat,
gpuAcceleration,
autostart,
pdfPreview,
@@ -236,6 +251,8 @@ export async function setGeneralSettings(
showTrayIcon,
closeToTray,
enableWikiLinks,
showNoteDates,
restoreLastSession,
});
}
@@ -259,6 +276,19 @@ export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats");
}
export interface OrphanAttachment {
name: string;
size: number;
}
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
return invoke("find_orphaned_attachments");
}
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
return invoke("trash_orphaned_attachments", { names });
}
export async function importObsidian(): Promise<void> {
return invoke("import_obsidian");
}
@@ -323,6 +353,61 @@ export async function setBackupSettings(
});
}
// ── Tasks ──
export async function getTasks(): Promise<TaskItem[]> {
return invoke("get_tasks");
}
export async function setTaskDone(
notePath: string,
line: number,
rawLine: string,
done: boolean,
): Promise<void> {
return invoke("set_task_done", { notePath, line, rawLine, done });
}
export async function setTaskPriority(
notePath: string,
line: number,
rawLine: string,
priority: string | null,
): Promise<void> {
return invoke("set_task_priority", { notePath, line, rawLine, priority });
}
export async function setTaskDue(
notePath: string,
line: number,
rawLine: string,
due: string | null,
): Promise<void> {
return invoke("set_task_due", { notePath, line, rawLine, due });
}
// ── Sync (WebDAV) ──
export async function setSyncSettings(
provider: string | null,
url: string | null,
username: string | null,
password: string | null,
syncOnOpen: boolean,
syncOnChange: boolean,
syncIntervalMinutes: number,
): Promise<void> {
return invoke("set_sync_settings", { provider, url, username, password, syncOnOpen, syncOnChange, syncIntervalMinutes });
}
export async function testSyncConnection(): Promise<void> {
return invoke("test_sync_connection");
}
export async function syncNow(): Promise<void> {
return invoke("sync_now");
}
// ── Version History ──
export async function getNoteVersions(noteId: string): Promise<VersionEntry[]> {
@@ -351,8 +436,14 @@ export async function setAiSettings(
model: string,
writingStyle: string | null,
baseUrl: string | null = null,
ollamaApiKey: string | null = null,
openaiCompatibleBaseUrl: string | null = null,
openaiCompatibleApiKey: string | null = null,
): Promise<void> {
return invoke("set_ai_settings", { provider, apiKey, model, writingStyle, baseUrl });
return invoke("set_ai_settings", {
provider, apiKey, model, writingStyle, baseUrl,
ollamaApiKey, openaiCompatibleBaseUrl, openaiCompatibleApiKey,
});
}
export async function testAiConnection(): Promise<void> {
@@ -372,6 +463,10 @@ export async function getInstallType(): Promise<string> {
return invoke("get_install_type");
}
export async function isMobilePlatform(): Promise<boolean> {
return invoke("is_mobile_platform");
}
export async function getPendingOpenFile(): Promise<string | null> {
return invoke("get_pending_open_file");
}
+248 -39
View File
@@ -33,25 +33,43 @@
quickAccessPaths,
tags,
notes,
notebooks,
rootNoteCount,
viewMode,
sortMode,
tasksLayout,
tasksHideCompleted,
tasksOnlyFlagged,
tasksSort,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab,
navHistory,
viewerNote,
notebookSortMode,
notebookOrder
notebookOrder,
syncState,
platformIsMobile
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme } from '$lib/api';
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store';
import type { VaultState, FileEvent } from '$lib/types';
import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
for (const nb of list) {
if (nb.relative_path === relPath) return nb;
const found = findNotebookByPath(nb.children, relPath);
if (found) return found;
}
return null;
}
let sidebar: Sidebar;
let noteList: NoteList;
@@ -63,6 +81,11 @@
let noteRelativePath = $derived($activeNotePath && $appConfig?.active_vault ? $activeNotePath.replace($appConfig.active_vault + '/', '') : '');
let isQuickAccess = $derived(noteRelativePath ? $quickAccessPaths.includes(noteRelativePath) : false);
let backupInterval: ReturnType<typeof setInterval> | null = null;
let syncInterval: ReturnType<typeof setInterval> | null = null;
let unlistenSync: Array<() => void> = [];
let unsubDirty: (() => void) | null = null;
let onChangeSyncTimer: ReturnType<typeof setTimeout> | null = null;
let prevDirty = false;
@@ -92,6 +115,27 @@
}
}
function syncConfigured(): boolean {
return get(appConfig)?.sync_provider === 'webdav';
}
// Auto-sync on a timer (only when configured and an interval is set).
async function checkScheduledSync() {
const config = get(appConfig);
if (config?.sync_provider !== 'webdav') return;
const mins = config.sync_interval_minutes ?? 0;
if (!mins || get(syncState).running) return;
const last = config.last_sync_time ? new Date(config.last_sync_time).getTime() : 0;
if (Date.now() - last >= mins * 60 * 1000) {
try { await syncNow(); } catch (_) {}
}
}
export async function triggerSyncNow() {
if (!syncConfigured() || get(syncState).running) return;
try { await syncNow(); } catch (_) {}
}
// Track note navigation in history stack
$effect(() => {
const path = $activeNotePath;
@@ -157,7 +201,15 @@
sidebar_collapsed: $sidebarCollapsed,
collapsed_notebooks: $collapsedNotebooks,
notebook_sort_mode: $notebookSortMode,
notebook_order: $notebookOrder
notebook_order: $notebookOrder,
sort_mode: $sortMode,
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag,
tasks_layout: $tasksLayout,
tasks_hide_completed: $tasksHideCompleted,
tasks_only_flagged: $tasksOnlyFlagged,
tasks_sort: $tasksSort
};
try {
await saveVaultState(state);
@@ -174,14 +226,19 @@
persistState();
}
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false);
function handleNoteSelected(path: string, content: string) {
// Selecting a real vault note exits viewer mode
$viewerNote = null;
taskNoteOpened = true;
editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor';
}
function handleViewChanged() {
taskNoteOpened = false;
noteList?.refresh();
if (isMobile) $mobileView = 'notelist';
}
@@ -192,6 +249,63 @@
if (isMobile) $mobileView = 'editor';
}
// Toggle a task done from the Tasks view. If the task's note is open in the editor,
// flush first then reload after the file edit (so we never clobber unsaved edits).
async function toggleTask(task: TaskItem) {
const done = !task.completed;
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskDone(task.note_path, task.line, task.raw_line, done);
} catch (e) {
console.error('Failed to toggle task:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function changeTaskPriority(task: TaskItem, priority: string | null) {
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskPriority(task.note_path, task.line, task.raw_line, priority);
} catch (e) {
console.error('Failed to set task priority:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function changeTaskDue(task: TaskItem, due: string | null) {
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskDue(task.note_path, task.line, task.raw_line, due);
} catch (e) {
console.error('Failed to set task due date:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function handleDailyNote() {
try {
const entry = await createDailyNote();
@@ -216,7 +330,7 @@
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) {
if (get(platformIsMobile)) {
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
$effect(() => {
@@ -361,9 +475,15 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
@@ -388,8 +508,27 @@
persistState();
});
$effect(() => {
$sortMode;
$tasksLayout;
$tasksHideCompleted;
$tasksOnlyFlagged;
$tasksSort;
persistState();
});
$effect(() => {
$viewMode;
$activeNotebook;
$activeTag;
persistState();
});
onMount(async () => {
let lastNotePath: string | null = null;
let lastViewMode = '';
let lastNotebook: string | null = null;
let lastTag: string | null = null;
try {
const state = await loadVaultState();
$sidebarWidth = state.sidebar_width;
@@ -398,7 +537,15 @@
$collapsedNotebooks = state.collapsed_notebooks ?? [];
$notebookSortMode = state.notebook_sort_mode === 'manual' ? 'manual' : 'alphabetical';
$notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
lastNotePath = state.last_open_note ?? null;
lastViewMode = state.last_view_mode ?? '';
lastNotebook = state.last_notebook ?? null;
lastTag = state.last_tag ?? null;
} catch (_) {}
// On mobile, prefetch last-opened note so first tap is instant
@@ -416,6 +563,34 @@
// Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
// Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) {
const vault = $appConfig?.active_vault;
if (lastViewMode === 'notebook' && lastNotebook === '' && vault) {
$viewMode = 'notebook';
$activeNotebook = { name: 'Unfiled Notes', path: vault, relative_path: '', children: [], note_count: $rootNoteCount };
$activeTag = null;
await noteList?.refresh();
} else if (lastViewMode === 'notebook' && lastNotebook) {
const nb = findNotebookByPath($notebooks, lastNotebook);
if (nb) { $viewMode = 'notebook'; $activeNotebook = nb; $activeTag = null; await noteList?.refresh(); }
} else if (lastViewMode === 'tag' && lastTag) {
$viewMode = 'tag'; $activeTag = lastTag; $activeNotebook = null; await noteList?.refresh();
} else if (lastViewMode === 'quickaccess') {
$viewMode = 'quickaccess'; $activeNotebook = null; $activeTag = null; await noteList?.refresh();
}
// Open the last note on desktop (mobile reopens it via its own prefetch path below).
if (!isMobile && lastNotePath) {
try {
const content = await readNote(lastNotePath);
$activeNote = content;
$activeNotePath = lastNotePath;
$editorDirty = false;
editor?.loadNote(lastNotePath, content.content);
} catch (_) {}
}
}
// On mobile, derive tags from the scanned notes (avoids a separate full-scan Rust call)
if (isMobile) {
const tagMap = new Map<string, number>();
@@ -480,12 +655,44 @@
// Scheduled backup: check on startup and every 5 minutes
checkScheduledBackup();
backupInterval = setInterval(checkScheduledBackup, 5 * 60 * 1000);
// ── WebDAV sync: global status + auto-sync triggers ──
unlistenSync.push(await listen('sync-progress', () => syncState.set({ running: true, error: null })));
unlistenSync.push(await listen('sync-done', (event: any) => {
syncState.set({ running: false, error: null });
const cur = get(appConfig);
if (cur && event.payload?.last_sync_time) appConfig.set({ ...cur, last_sync_time: event.payload.last_sync_time });
}));
unlistenSync.push(await listen('sync-error', (event: any) => syncState.set({ running: false, error: event.payload?.error ?? 'Sync failed' })));
// Sync when the vault opens (if enabled)
if (syncConfigured() && get(appConfig)?.sync_on_open) {
try { await syncNow(); } catch (_) {}
}
// Auto-sync interval: check on startup and every minute
checkScheduledSync();
syncInterval = setInterval(checkScheduledSync, 60 * 1000);
// Auto-sync on note change: a save flips editorDirty true -> false. Debounce a sync.
unsubDirty = editorDirty.subscribe((d) => {
const config = get(appConfig);
if (prevDirty && !d && config?.sync_provider === 'webdav' && config?.sync_on_change) {
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
onChangeSyncTimer = setTimeout(() => { if (!get(syncState).running) syncNow().catch(() => {}); }, 15000);
}
prevDirty = d;
});
});
onDestroy(() => {
unlistenFileChange?.();
unlistenOpenFile?.();
if (backupInterval) clearInterval(backupInterval);
if (syncInterval) clearInterval(syncInterval);
if (onChangeSyncTimer) clearTimeout(onChangeSyncTimer);
unsubDirty?.();
unlistenSync.forEach((u) => u());
});
</script>
@@ -518,7 +725,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
@@ -579,13 +786,20 @@
</svg>
</button>
{/if}
{#if $appConfig?.ai_provider && ($appConfig?.ai_provider === 'ollama' || $appConfig?.ai_api_key || $appConfig?.openai_api_key)}
{#if $appConfig?.ai_provider}
<button class="mobile-header-btn" onclick={() => editor?.triggerAiMenu()} title="AI Actions">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 8V4l-2-2"/><rect x="4" y="8" width="16" height="12" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 13v2"/><path d="M15 13v2"/>
</svg>
</button>
{/if}
{#if $appConfig?.sync_provider === 'webdav'}
<button class="mobile-header-btn" class:active={$syncState.running} onclick={triggerSyncNow} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing...' : 'Sync now')}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
</svg>
</button>
{/if}
<button class="mobile-header-btn" class:active={$sourceMode} onclick={() => ($sourceMode = !$sourceMode)} title={$sourceMode ? 'Rich Editor' : 'Source Mode'}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6" /><polyline points="8 6 2 12 8 18" />
@@ -622,12 +836,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" />
</svg>
</button>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} />
@@ -687,7 +896,7 @@
{/if}
<div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => editor?.focusTitle()} />
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div>
<ResizeHandle onResize={handleNotelistResize} />
@@ -695,6 +904,14 @@
<div class="editor-panel">
<Editor bind:this={editor} />
{#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
<p>Select a task to open its note</p>
</div>
{/if}
</div>
</div>
</div>
@@ -737,6 +954,21 @@
height: 100%;
overflow: hidden;
min-width: 300px;
position: relative;
}
.tasks-editor-placeholder {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 14px;
}
.focus-topbar {
@@ -904,27 +1136,4 @@
pointer-events: auto;
}
.mobile-fab {
position: absolute;
bottom: 24px;
right: 20px;
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--accent);
color: white;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
cursor: pointer;
z-index: 10;
}
.mobile-fab:active {
transform: scale(0.93);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
</style>
+47 -1
View File
@@ -55,6 +55,46 @@
$showCommandPalette = false;
}
},
{
id: 'theme-solarized-light',
label: 'Switch to Solarized Light Theme',
action: () => { $theme = 'solarized-light'; setTheme('solarized-light'); applyTheme('solarized-light'); $showCommandPalette = false; }
},
{
id: 'theme-solarized-dark',
label: 'Switch to Solarized Dark Theme',
action: () => { $theme = 'solarized-dark'; setTheme('solarized-dark'); applyTheme('solarized-dark'); $showCommandPalette = false; }
},
{
id: 'theme-catppuccin',
label: 'Switch to Catppuccin Theme',
action: () => { $theme = 'catppuccin'; setTheme('catppuccin'); applyTheme('catppuccin'); $showCommandPalette = false; }
},
{
id: 'theme-nord',
label: 'Switch to Nord Theme',
action: () => { $theme = 'nord'; setTheme('nord'); applyTheme('nord'); $showCommandPalette = false; }
},
{
id: 'theme-tokyo-night',
label: 'Switch to Tokyo Night Theme',
action: () => { $theme = 'tokyo-night'; setTheme('tokyo-night'); applyTheme('tokyo-night'); $showCommandPalette = false; }
},
{
id: 'theme-github-light',
label: 'Switch to GitHub Light Theme',
action: () => { $theme = 'github-light'; setTheme('github-light'); applyTheme('github-light'); $showCommandPalette = false; }
},
{
id: 'theme-github-dark',
label: 'Switch to GitHub Dark Theme',
action: () => { $theme = 'github-dark'; setTheme('github-dark'); applyTheme('github-dark'); $showCommandPalette = false; }
},
{
id: 'theme-dracula',
label: 'Switch to Dracula Theme',
action: () => { $theme = 'dracula'; setTheme('dracula'); applyTheme('dracula'); $showCommandPalette = false; }
},
{
id: 'toggle-source',
label: 'Toggle Source/WYSIWYG Mode',
@@ -102,9 +142,15 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
}
File diff suppressed because it is too large Load Diff
+5 -3
View File
@@ -8,7 +8,7 @@
onnavigate: (path: string, title: string) => void;
} = $props();
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let canvas = $state<HTMLCanvasElement>(null!);
let loading = $state(true);
@@ -76,8 +76,10 @@
}
function getFolderKey(path: string): string {
const vaultRoot = $appConfig?.active_vault || '';
const rel = vaultRoot && path.startsWith(vaultRoot + '/') ? path.slice(vaultRoot.length + 1) : path;
// Normalize Windows backslashes so folder grouping/colours work there too.
const p = (path || '').replace(/\\/g, '/');
const vaultRoot = ($appConfig?.active_vault || '').replace(/\\/g, '/');
const rel = vaultRoot && p.startsWith(vaultRoot + '/') ? p.slice(vaultRoot.length + 1) : p;
const slash = rel.indexOf('/');
return slash >= 0 ? rel.slice(0, slash) : '';
}
+109 -2
View File
@@ -1,16 +1,21 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -35,8 +40,26 @@
getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else {
stats = null;
orphans = null;
}
});
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script>
{#if $showInfo}
@@ -103,6 +126,25 @@
</div>
{/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p>
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
@@ -350,6 +392,71 @@
.info-link:hover {
background: var(--accent-light);
}
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section {
width: 100%;
+77 -70
View File
@@ -34,20 +34,25 @@
getAllTags,
createDailyNote
} from '$lib/api';
import { formatRelativeTime } from '$lib/utils/time';
import { formatRelativeTime, formatDate } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types';
import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
import TagSuggestInput from './TagSuggestInput.svelte';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: {
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent);
let multiSelectMode = $state(false);
let trashNotebooks = $state<TrashNotebookEntry[]>([]);
@@ -59,14 +64,15 @@
let dailyDates = $state<Set<string>>(new Set());
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const calDayNames = ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'];
// 0 = Sunday, 1 = Monday (default). Day names + grid offset follow the setting.
const weekStartsOn = $derived($appConfig?.week_start === 'sunday' ? 0 : 1);
const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
function calDays(): Array<{ day: number; date: string; current: boolean }> {
const first = new Date(calYear, calMonth, 1);
const lastDay = new Date(calYear, calMonth + 1, 0).getDate();
// Monday=0 offset
let startDow = first.getDay() - 1;
if (startDow < 0) startDow = 6;
const startDow = (first.getDay() - weekStartsOn + 7) % 7;
const cells: Array<{ day: number; date: string; current: boolean }> = [];
for (let i = 0; i < startDow; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDay; d++) {
@@ -127,18 +133,18 @@
if (isMobile) {
deriveTagsFromNotes();
} else {
await refreshTags();
try { $tags = await getAllTags(); } catch (_) {}
}
}
let compact = $derived($appConfig?.compact_notes ?? false);
let showDates = $derived($appConfig?.show_note_dates !== false);
let contextMenu = $state<{ x: number; y: number; note: NoteEntry } | null>(null);
let sortMenu = $state<{ x: number; y: number } | null>(null);
let editingNote = $state<string | null>(null);
let editValue = $state('');
let movePickerNote = $state<NoteEntry | null>(null);
let tagEditNote = $state<NoteEntry | null>(null);
let tagEditValue = $state('');
let tagEditTags = $state<string[]>([]);
// Multi-select
@@ -280,6 +286,7 @@
if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'tasks') return 'Tasks';
if ($viewMode === 'trash') return 'Trash';
return 'Notes';
});
@@ -297,12 +304,15 @@
}
export async function refresh(invalidate = false) {
if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data
if (invalidate) {
noteCache.clear();
}
const key = cacheKey();
const cached = noteCache.get(key);
// Never serve the trash view from cache: emptying / restoring / deleting changes it,
// and a stale cache left it showing cleared items until an app restart. Always re-read.
const cached = $viewMode === 'trash' ? undefined : noteCache.get(key);
if (cached) {
$notes = cached;
return;
@@ -365,6 +375,19 @@
}
}
async function openTask(task: TaskItem) {
try {
const content = await readNote(task.note_path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = task.note_path;
$editorDirty = false;
onNoteSelected(task.note_path, content.content);
} catch (e) {
console.error('Failed to open task note:', e);
}
}
export async function handleCreateNote() {
if ($viewMode === 'daily') {
const today = new Date();
@@ -374,7 +397,19 @@
calYear = today.getFullYear();
return;
}
const nbRelative = $viewMode === 'notebook' ? $activeNotebook?.relative_path ?? null : null;
// Target: active notebook, else the open note's folder, else root.
let nbRelative: string | null = null;
if ($viewMode === 'notebook' && $activeNotebook) {
nbRelative = $activeNotebook.relative_path || null;
} else if ($appConfig?.active_vault && $activeNotePath) {
const vaultN = $appConfig.active_vault.replace(/\\/g, '/');
const apN = $activeNotePath.replace(/\\/g, '/');
if (apN.startsWith(vaultN + '/')) {
const rel = apN.slice(vaultN.length + 1);
const slash = rel.lastIndexOf('/');
if (slash > 0) nbRelative = rel.slice(0, slash);
}
}
try {
const entry = await createNote(nbRelative, 'Untitled');
noteCache.clear();
@@ -500,7 +535,7 @@
function openTagEdit(note: NoteEntry) {
tagEditNote = note;
tagEditTags = [...note.meta.tags];
tagEditValue = '';
refreshTags();
}
async function addTagToNote(tag: string) {
@@ -508,7 +543,6 @@
const cleaned = tag.trim().toLowerCase();
if (tagEditTags.includes(cleaned)) return;
tagEditTags = [...tagEditTags, cleaned];
tagEditValue = '';
await saveTagsForNote(tagEditNote, tagEditTags);
}
@@ -537,7 +571,7 @@
function openBatchTagEdit() {
batchTagEdit = true;
tagEditValue = '';
refreshTags();
// Show tags common to ALL selected notes
const selectedNotes = $notes.filter(n => selectedPaths.has(n.path));
if (selectedNotes.length === 0) { tagEditTags = []; return; }
@@ -549,7 +583,6 @@
async function addTagToBatch(tag: string) {
if (!tag.trim()) return;
const cleaned = tag.trim().toLowerCase();
tagEditValue = '';
if (!tagEditTags.includes(cleaned)) tagEditTags = [...tagEditTags, cleaned];
try {
for (const path of selectedPaths) {
@@ -823,6 +856,7 @@
<div class="list-header">
<span class="list-title">{viewTitle}</span>
<div class="list-actions">
{#if $viewMode !== 'tasks'}
{#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -842,6 +876,7 @@
</svg>
</button>
{/if}
{/if}
</div>
</div>
@@ -906,16 +941,11 @@
</div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
<TagSuggestInput
existing={tagEditTags}
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
onsubmit={(t) => addTagToBatch(t)}
oncancel={() => batchTagEdit = false}
/>
</div>
{#if tagEditTags.length > 0}
@@ -938,6 +968,9 @@
{/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if}
{#if $viewMode === 'daily'}
<div class="cal">
<div class="cal-nav">
@@ -972,7 +1005,7 @@
</div>
{/if}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && $viewMode !== 'tasks' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
<div class="empty-state">
{#if $viewMode === 'trash'}
<p>Trash is empty</p>
@@ -1111,7 +1144,7 @@
{#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span>
{/if}
<span class="note-date-compact">{formatRelativeTime(note.meta.modified)}</span>
{#if showDates}<span class="note-date-compact" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
</div>
{:else}
<div class="note-title" title={getNotebookPath(note) ? `${getNotebookPath(note)}/${note.meta.title}` : note.meta.title}>
@@ -1131,9 +1164,9 @@
<div class="note-meta">
{#if getNotebookPath(note)}
<span class="note-notebook">{getNotebookPath(note)}</span>
<span class="note-meta-sep">&middot;</span>
{#if showDates}<span class="note-meta-sep">&middot;</span>{/if}
{/if}
<span class="note-date">{formatRelativeTime(note.meta.modified)}</span>
{#if showDates}<span class="note-date" title={`Created ${formatDate(note.meta.created)}\nModified ${formatDate(note.meta.modified)}`}>{formatRelativeTime($sortMode === 'created' ? note.meta.created : note.meta.modified)}</span>{/if}
{#if note.meta.tags.length > 0}
<span class="note-tags">
{#each note.meta.tags.slice(0, 3) as tag}
@@ -1171,16 +1204,11 @@
<div class="context-sep"></div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
<TagSuggestInput
existing={tagEditTags}
placeholder="Add tag to all..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToBatch(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); batchTagEdit = false; }
}}
autofocus
onsubmit={(t) => addTagToBatch(t)}
oncancel={() => batchTagEdit = false}
/>
</div>
{#if tagEditTags.length > 0}
@@ -1228,16 +1256,11 @@
<div class="context-sep"></div>
<div class="tag-edit-section">
<div class="tag-edit-input-row">
<input
type="text"
class="tag-edit-input"
<TagSuggestInput
existing={tagEditTags}
placeholder="Add tag..."
bind:value={tagEditValue}
onkeydown={(e) => {
if (e.key === 'Enter') { e.preventDefault(); addTagToNote(tagEditValue); }
if (e.key === 'Escape') { e.preventDefault(); tagEditNote = null; }
}}
autofocus
onsubmit={(t) => addTagToNote(t)}
oncancel={() => tagEditNote = null}
/>
</div>
{#if tagEditTags.length > 0}
@@ -1820,21 +1843,6 @@
padding: 2px 8px 4px;
}
.tag-edit-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
outline: none;
}
.tag-edit-input:focus {
border-color: var(--accent);
}
.tag-edit-list {
max-height: 160px;
overflow-y: auto;
@@ -2200,8 +2208,7 @@
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 4px 2px;
padding: 0 2px;
gap: 2px;
}
.cal-head {
@@ -2213,19 +2220,18 @@
}
.cal-cell {
position: relative;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.72rem;
width: 28px;
height: 28px;
border-radius: 50%;
border: none;
font-size: 12px;
border: 1px solid transparent;
border-radius: 6px;
background: none;
color: var(--text-secondary);
cursor: pointer;
padding: 0;
margin: 0 auto;
}
.cal-cell.empty {
@@ -2237,6 +2243,7 @@
}
.cal-cell.today {
border-color: var(--accent);
color: var(--accent);
font-weight: 700;
}
+8 -2
View File
@@ -34,10 +34,16 @@
});
$effect(() => {
const themeValue = $theme || 'system';
const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
});
+69
View File
@@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { isMobile } from '$lib/platform';
type Dir =
| 'North' | 'South' | 'East' | 'West'
| 'NorthEast' | 'NorthWest' | 'SouthEast' | 'SouthWest';
// Resolved only on the client (onMount), so SSR/prerender never calls Tauri APIs.
let appWindow = $state<ReturnType<typeof getCurrentWindow> | null>(null);
let maximized = $state(false);
onMount(() => {
if (isMobile) return;
const win = getCurrentWindow();
appWindow = win;
const refresh = () => win.isMaximized().then((m) => (maximized = m)).catch(() => {});
refresh();
const unlisten = win.onResized(() => refresh());
return () => { unlisten.then((fn) => fn()); };
});
function start(e: MouseEvent, dir: Dir) {
if (e.button !== 0 || !appWindow) return;
e.preventDefault();
appWindow.startResizeDragging(dir);
}
</script>
{#if appWindow && !maximized}
<div class="resize-layer" aria-hidden="true">
<div class="rz rz-n" onmousedown={(e) => start(e, 'North')}></div>
<div class="rz rz-s" onmousedown={(e) => start(e, 'South')}></div>
<div class="rz rz-w" onmousedown={(e) => start(e, 'West')}></div>
<div class="rz rz-e" onmousedown={(e) => start(e, 'East')}></div>
<div class="rz rz-nw" onmousedown={(e) => start(e, 'NorthWest')}></div>
<div class="rz rz-ne" onmousedown={(e) => start(e, 'NorthEast')}></div>
<div class="rz rz-sw" onmousedown={(e) => start(e, 'SouthWest')}></div>
<div class="rz rz-se" onmousedown={(e) => start(e, 'SouthEast')}></div>
</div>
{/if}
<style>
/* Transparent overlay; only the strips capture events, the rest passes through. */
.resize-layer {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 99999;
}
.rz {
position: fixed;
pointer-events: auto;
}
/* Edges, inset by the corner size so corners win at the corners. */
.rz-n { top: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
.rz-s { bottom: 0; left: 14px; right: 14px; height: 6px; cursor: ns-resize; }
.rz-w { left: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
.rz-e { right: 0; top: 14px; bottom: 14px; width: 6px; cursor: ew-resize; }
/* Corners: larger, reliable grab targets. */
.rz-nw { top: 0; left: 0; width: 14px; height: 14px; cursor: nwse-resize; }
.rz-ne { top: 0; right: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.rz-sw { bottom: 0; left: 0; width: 14px; height: 14px; cursor: nesw-resize; }
.rz-se { bottom: 0; right: 0; width: 14px; height: 14px; cursor: nwse-resize; }
</style>
+1 -1
View File
@@ -83,7 +83,7 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
async function openResult(result: SearchResult) {
try {
File diff suppressed because it is too large Load Diff
+97 -16
View File
@@ -31,7 +31,7 @@
} = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
let editingNotebook = $state<string | null>(null);
let editValue = $state('');
@@ -67,6 +67,22 @@
$collapsedNotebooks = [...$collapsedNotebooks, path];
}
}
// Collapse-all / expand-all: toggles every notebook that has children.
function collectParentPaths(tree: NotebookEntry[]): string[] {
const out: string[] = [];
for (const nb of tree) {
if (nb.children && nb.children.length > 0) {
out.push(nb.path);
out.push(...collectParentPaths(nb.children));
}
}
return out;
}
const allParentPaths = $derived(collectParentPaths($notebooks));
const allCollapsed = $derived(allParentPaths.length > 0 && allParentPaths.every((p) => $collapsedNotebooks.includes(p)));
function toggleAllCollapse() {
$collapsedNotebooks = allCollapsed ? [] : [...allParentPaths];
}
export async function refresh() {
@@ -150,6 +166,13 @@
onViewChanged();
}
function selectTasks() {
$viewMode = 'tasks';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() {
$viewMode = 'trash';
$activeNotebook = null;
@@ -511,6 +534,18 @@
<span>Quick Access</span>
</button>
<button
class="nav-item"
class:active={$viewMode === 'tasks'}
onclick={selectTasks}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
<span>Tasks</span>
</button>
<button
class="nav-item"
class:active={$viewMode === 'daily'}
@@ -559,24 +594,40 @@
}}
>
<span class="section-title">Notebooks</span>
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<div class="section-actions">
{#if allParentPaths.length > 0}
<button class="icon-btn-sm" onclick={(e) => { e.stopPropagation(); toggleAllCollapse(); }} title={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'} aria-label={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if allCollapsed}
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
{:else}
<path d="m7 20 5-5 5 5" /><path d="m7 4 5 5 5-5" />
{/if}
</svg>
</button>
{/if}
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
{#if showNewNotebook}
<div class="new-notebook-input">
{#if newNotebookParent}
<span class="new-notebook-parent">{newNotebookParent.name}/</span>
<button class="new-notebook-parent" title="Click to create a top-level notebook instead" onmousedown={(e) => { e.preventDefault(); newNotebookParent = null; }}>
{newNotebookParent.name}/<span class="new-notebook-parent-x" aria-hidden="true">×</span>
</button>
{/if}
<input
type="text"
@@ -792,8 +843,14 @@
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
ondrop={(e) => {
e.preventDefault();
const pos = dropPosition;
if (draggedNotebookPath) {
// Recompute the drop position from the drop coordinates, not the dragover-set
// state: dragleave over the row's child nodes (chevron/icon/label) clears
// dropPosition before drop (esp. Windows WebView2), which mis-routed every drop
// to "nest" and broke reorder + un-nest. (issue #97)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
const pos = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
if (pos === 'above' || pos === 'below') {
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
} else {
@@ -924,6 +981,11 @@
color: var(--text-tertiary);
}
.section-actions {
display: flex;
align-items: center;
gap: 2px;
}
.icon-btn, .icon-btn-sm {
background: none;
border: none;
@@ -969,11 +1031,30 @@
}
.new-notebook-parent {
padding: 4px 0 4px 8px;
display: inline-flex;
align-items: center;
gap: 2px;
margin: 0 2px 0 6px;
padding: 2px 5px;
color: var(--text-tertiary);
font-size: inherit;
white-space: nowrap;
flex-shrink: 0;
border: none;
border-radius: 4px;
background: var(--bg-hover);
cursor: pointer;
}
.new-notebook-parent:hover {
color: var(--text-primary);
background: var(--accent-light);
}
.new-notebook-parent-x {
font-size: 13px;
line-height: 1;
opacity: 0.7;
}
.new-notebook-input input {
+128
View File
@@ -0,0 +1,128 @@
<script lang="ts">
import { onMount } from 'svelte';
import { tags } from '$lib/stores/app';
let { existing = [], placeholder = 'Add tag...', onsubmit, oncancel }: {
existing?: string[];
placeholder?: string;
onsubmit: (tag: string) => void;
oncancel: () => void;
} = $props();
let query = $state('');
let selIndex = $state(-1);
let inputEl = $state<HTMLInputElement>(null!);
// Existing tags that match what's typed, prefix matches first, already-applied ones excluded.
const suggestions = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
const taken = new Set(existing);
const names = $tags
.map(([name]) => name)
.filter((name) => !taken.has(name) && name.toLowerCase().includes(q));
names.sort((a, b) => {
const ap = a.toLowerCase().startsWith(q) ? 0 : 1;
const bp = b.toLowerCase().startsWith(q) ? 0 : 1;
if (ap !== bp) return ap - bp;
return a.localeCompare(b);
});
return names.slice(0, 8);
});
function submit(tag: string) {
if (!tag.trim()) return;
onsubmit(tag);
query = '';
selIndex = -1;
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'ArrowDown') {
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex + 1) % suggestions.length; }
} else if (e.key === 'ArrowUp') {
if (suggestions.length) { e.preventDefault(); selIndex = (selIndex - 1 + suggestions.length) % suggestions.length; }
} else if (e.key === 'Enter') {
e.preventDefault();
submit(selIndex >= 0 && selIndex < suggestions.length ? suggestions[selIndex] : query);
} else if (e.key === 'Tab') {
// Complete the input to the highlighted suggestion without committing.
if (selIndex >= 0 && selIndex < suggestions.length) { e.preventDefault(); query = suggestions[selIndex]; selIndex = -1; }
} else if (e.key === 'Escape') {
e.preventDefault();
if (selIndex >= 0) selIndex = -1; // first Escape just drops the highlight
else oncancel();
}
}
onMount(() => inputEl?.focus());
</script>
<div class="tag-suggest">
<input
bind:this={inputEl}
type="text"
class="tag-suggest-input"
{placeholder}
bind:value={query}
oninput={() => (selIndex = -1)}
onkeydown={onKeydown}
/>
{#if suggestions.length}
<div class="tag-suggest-list">
{#each suggestions as s, i}
<button
type="button"
class="tag-suggest-item"
class:selected={i === selIndex}
onmouseenter={() => (selIndex = i)}
onmousedown={(e) => { e.preventDefault(); submit(s); }}
>#{s}</button>
{/each}
</div>
{/if}
</div>
<style>
.tag-suggest {
display: flex;
flex-direction: column;
}
.tag-suggest-input {
width: 100%;
padding: 4px 8px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
outline: none;
font-family: inherit;
}
.tag-suggest-input:focus {
border-color: var(--accent);
}
.tag-suggest-list {
margin-top: 4px;
max-height: 180px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 1px;
}
.tag-suggest-item {
text-align: left;
padding: 5px 8px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.tag-suggest-item:hover,
.tag-suggest-item.selected {
background: var(--bg-hover);
color: var(--text-primary);
}
</style>
+712
View File
@@ -0,0 +1,712 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getTasks } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { tasksLayout, tasksHideCompleted, tasksOnlyFlagged, tasksSort, appConfig } from '$lib/stores/app';
import { isAndroid } from '$lib/platform';
import type { TaskItem, FileEvent } from '$lib/types';
let { onOpenTask = (_t: TaskItem) => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onOpenTask?: (t: TaskItem) => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
} = $props();
let tasks = $state<TaskItem[]>([]);
let loading = $state(true);
let taskQuery = $state('');
let unlisten: (() => void) | null = null;
async function load() {
try {
tasks = await getTasks();
} catch (e) {
console.error('Failed to load tasks:', e);
tasks = [];
}
loading = false;
}
const debouncedLoad = debounce(load, 400);
onMount(() => {
load();
listen<FileEvent>('file-changed', () => debouncedLoad()).then((u) => (unlisten = u));
return () => { unlisten?.(); };
});
// Local YYYY-MM-DD (en-CA gives ISO-style date)
const today = new Date().toLocaleDateString('en-CA');
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
// Free-text task filter: matches the task text or its note title (case-insensitive).
function matchesQuery(t: TaskItem): boolean {
const q = taskQuery.trim().toLowerCase();
if (!q) return true;
return t.text.toLowerCase().includes(q) || t.note_title.toLowerCase().includes(q);
}
const filtered = $derived.by(() => {
let list = tasks.slice();
if ($tasksHideCompleted) list = list.filter((t) => !t.completed);
if ($tasksOnlyFlagged) list = list.filter((t) => !!t.due || !!t.priority);
list = list.filter(matchesQuery);
list.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
if ($tasksSort === 'priority') {
const d = prank(a.priority) - prank(b.priority);
if (d) return d;
} else if ($tasksSort === 'note') {
const d = a.note_title.localeCompare(b.note_title);
if (d) return d;
} else {
const ad = a.due ?? '9999-99-99';
const bd = b.due ?? '9999-99-99';
if (ad !== bd) return ad < bd ? -1 : 1;
}
return a.text.localeCompare(b.text);
});
return list;
});
const openCount = $derived(tasks.filter((t) => !t.completed).length);
function dueClass(due: string | null): string {
if (!due) return '';
if (due < today) return 'overdue';
if (due === today) return 'duetoday';
return '';
}
async function toggle(t: TaskItem) {
await onToggleTask(t);
await load();
}
let prioMenuFor = $state<string | null>(null);
let dueMenuFor = $state<string | null>(null);
const rowKey = (t: TaskItem) => t.note_path + ':' + t.line + ':' + t.raw_line;
async function choosePriority(t: TaskItem, p: string | null) {
prioMenuFor = null;
await onSetTaskPriority(t, p);
await load();
}
async function chooseDue(t: TaskItem, d: string | null) {
dueMenuFor = null;
await onSetTaskDue(t, d);
await load();
}
// ── Calendar ──
// Layout persists per-vault (tasksLayout store -> VaultState). Android is always List.
const viewLayout = $derived(isAndroid ? 'list' : $tasksLayout);
const calNow = new Date();
let calMonth = $state(calNow.getMonth());
let calYear = $state(calNow.getFullYear());
let selectedDate = $state(today);
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
// 0 = Sunday, 1 = Monday (default). Day names + grid offset follow the setting.
const weekStartsOn = $derived($appConfig?.week_start === 'sunday' ? 0 : 1);
const DOW = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
// Calendar tasks share the list's hide-completed filter (no sort).
const calBase = $derived(tasks.filter((t) => (!$tasksHideCompleted || !t.completed) && (!$tasksOnlyFlagged || !!t.due || !!t.priority) && matchesQuery(t)));
const tasksByDate = $derived.by(() => {
const m = new Map<string, TaskItem[]>();
for (const t of calBase) {
if (!t.due) continue;
const arr = m.get(t.due);
if (arr) arr.push(t);
else m.set(t.due, [t]);
}
return m;
});
const undatedTasks = $derived(calBase.filter((t) => !t.due));
function byPriority(a: TaskItem, b: TaskItem) {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
const d = prank(a.priority) - prank(b.priority);
return d || a.text.localeCompare(b.text);
}
const selectedDayTasks = $derived(
(selectedDate ? (tasksByDate.get(selectedDate) ?? []) : []).slice().sort(byPriority)
);
function calDays() {
const firstWeekday = (new Date(calYear, calMonth, 1).getDay() - weekStartsOn + 7) % 7;
const lastDate = new Date(calYear, calMonth + 1, 0).getDate();
const cells: { day: number; date: string; current: boolean }[] = [];
for (let i = 0; i < firstWeekday; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDate; d++) {
const date = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
cells.push({ day: d, date, current: date === today });
}
return cells;
}
function calPrev() {
if (calMonth === 0) { calMonth = 11; calYear -= 1; } else calMonth -= 1;
}
function calNext() {
if (calMonth === 11) { calMonth = 0; calYear += 1; } else calMonth += 1;
}
function calToday() {
const n = new Date();
calMonth = n.getMonth();
calYear = n.getFullYear();
selectedDate = today;
}
function agendaTitle(date: string): string {
if (date === today) return 'Today';
const [y, mo, d] = date.split('-').map(Number);
return new Date(y, mo - 1, d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
// Drag a task (from the agenda or "No due date") onto a day to (re)schedule it.
let draggedTask = $state<TaskItem | null>(null);
let dragOverDate = $state<string | null>(null);
async function dropOnDay(date: string) {
const t = draggedTask;
draggedTask = null;
dragOverDate = null;
if (!t || t.due === date) return;
await onSetTaskDue(t, date);
await load();
}
</script>
<div class="tasks-view">
{#if prioMenuFor || dueMenuFor}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
{/if}
<div class="tasks-controls">
<input class="tasks-filter" type="text" placeholder="Filter tasks…" bind:value={taskQuery} onkeydown={(e) => { if (e.key === 'Escape') taskQuery = ''; }} aria-label="Filter tasks by text" />
<button class="tasks-ctl" class:active={$tasksHideCompleted} onclick={() => ($tasksHideCompleted = !$tasksHideCompleted)} title="Hide completed tasks">Hide done</button>
<button class="tasks-ctl" class:active={$tasksOnlyFlagged} onclick={() => ($tasksOnlyFlagged = !$tasksOnlyFlagged)} title="Only show tasks with a due date or priority">Flagged</button>
{#if !isAndroid}
<div class="tasks-layout">
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
<button class="tasks-ctl" class:active={viewLayout === 'calendar'} onclick={() => ($tasksLayout = 'calendar')} title="Calendar view">Calendar</button>
</div>
{/if}
{#if viewLayout === 'list'}
<div class="tasks-sort">
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
<button class="tasks-ctl" class:active={$tasksSort === v} onclick={() => ($tasksSort = v as 'due' | 'priority' | 'note')}>{label}</button>
{/each}
</div>
{/if}
</div>
{#snippet taskRow(t: TaskItem, dragMode: boolean)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="task-row"
class:done={t.completed}
class:draggable={dragMode}
draggable={dragMode}
ondragstart={(e) => { if (!dragMode) return; draggedTask = t; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', rowKey(t)); } }}
ondragend={() => { draggedTask = null; dragOverDate = null; }}
>
<button class="task-check" class:checked={t.completed} onclick={(e) => { e.stopPropagation(); toggle(t); }} title={t.completed ? 'Mark not done' : 'Mark done'} aria-label="toggle done"></button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="task-main" onclick={() => onOpenTask(t)}>
<div class="task-line">
<span class="task-prio-wrap">
<button
class="task-prio prio-{t.priority ?? 'none'}"
class:set={!!t.priority}
title="Set priority"
aria-label="Set priority"
onclick={(e) => { e.stopPropagation(); dueMenuFor = null; prioMenuFor = prioMenuFor === rowKey(t) ? null : rowKey(t); }}
>{t.priority ?? '!'}</button>
{#if prioMenuFor === rowKey(t)}
<div class="prio-menu" role="menu">
<button class="prio-opt prio-high" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'high'); }}>High</button>
<button class="prio-opt prio-med" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'med'); }}>Med</button>
<button class="prio-opt prio-low" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'low'); }}>Low</button>
<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); choosePriority(t, null); }}>None</button>
</div>
{/if}
</span>
<span class="task-label">{t.text}</span>
<span class="task-due-wrap">
<button
class="task-due {dueClass(t.due)}"
class:set={!!t.due}
title="Set due date"
aria-label="Set due date"
onclick={(e) => { e.stopPropagation(); prioMenuFor = null; dueMenuFor = dueMenuFor === rowKey(t) ? null : rowKey(t); }}
>{t.due ?? 'due'}</button>
{#if dueMenuFor === rowKey(t)}
<div class="due-menu">
<input
class="due-input"
type="date"
value={t.due ?? ''}
onclick={(e) => e.stopPropagation()}
onchange={(e) => { e.stopPropagation(); chooseDue(t, (e.target as HTMLInputElement).value || null); }}
/>
{#if t.due}<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); chooseDue(t, null); }}>Clear</button>{/if}
</div>
{/if}
</span>
</div>
<div class="task-note">{t.note_title}</div>
</div>
</div>
{/snippet}
{#if loading}
<div class="tasks-empty">Loading...</div>
{:else if viewLayout === 'calendar'}
<div class="tasks-cal-pane">
<div class="cal">
<div class="cal-nav">
<button class="cal-nav-btn" onclick={calPrev} title="Previous month" aria-label="Previous month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="cal-title" onclick={calToday} title="Jump to today">{calMonthNames[calMonth]} {calYear}</button>
<button class="cal-nav-btn" onclick={calNext} title="Next month" aria-label="Next month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="cal-grid">
{#each calDayNames as name}<div class="cal-head">{name}</div>{/each}
{#each calDays() as cell}
{#if cell.day === 0}
<div class="cal-cell empty"></div>
{:else}
{@const cnt = (tasksByDate.get(cell.date) ?? []).length}
<button
class="cal-cell"
class:today={cell.current}
class:active={cell.date === selectedDate}
class:has-tasks={cnt > 0}
class:drop-over={dragOverDate === cell.date}
onclick={() => (selectedDate = cell.date)}
ondragover={(e) => { if (draggedTask) { e.preventDefault(); dragOverDate = cell.date; } }}
ondragleave={() => { if (dragOverDate === cell.date) dragOverDate = null; }}
ondrop={(e) => { e.preventDefault(); dropOnDay(cell.date); }}
>
<span class="cal-day">{cell.day}</span>
{#if cnt > 0}<span class="cal-count" class:overdue={cell.date < today}>{cnt}</span>{/if}
</button>
{/if}
{/each}
</div>
</div>
<div class="cal-agenda">
<div class="agenda-section">
<div class="agenda-head" class:overdue={selectedDate < today}>{agendaTitle(selectedDate)}</div>
{#if selectedDayTasks.length === 0}
<div class="tasks-empty-sm">Nothing due.</div>
{:else}
{#each selectedDayTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
{/if}
</div>
{#if undatedTasks.length}
<div class="agenda-section">
<div class="agenda-head">No due date ({undatedTasks.length})</div>
{#each undatedTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
</div>
{/if}
</div>
</div>
{:else if filtered.length === 0}
<div class="tasks-empty">No tasks. Add <code>- [ ] something</code> to any note.{#if openCount === 0 && tasks.length}{' '}All done!{/if}</div>
{:else}
<div class="tasks-list">
{#each filtered as t (rowKey(t))}{@render taskRow(t, false)}{/each}
</div>
{/if}
</div>
<style>
.tasks-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tasks-controls {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
flex-shrink: 0;
}
.tasks-sort {
display: flex;
gap: 2px;
margin-left: auto;
}
.tasks-layout {
display: flex;
gap: 2px;
}
.tasks-ctl {
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.tasks-ctl.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tasks-filter {
width: 150px;
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
}
.tasks-filter::placeholder { color: var(--text-tertiary); }
.tasks-filter:focus { outline: none; border-color: var(--accent); }
.tasks-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.tasks-empty {
padding: 24px 16px;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
}
.task-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 7px 12px;
border-bottom: 1px solid var(--border-color);
}
.task-row:hover {
background: var(--bg-hover);
}
.task-row.draggable { cursor: grab; }
.task-row.draggable:active { cursor: grabbing; }
.task-check {
width: 16px;
height: 16px;
margin-top: 2px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
cursor: pointer;
flex-shrink: 0;
position: relative;
}
.task-check:hover {
border-color: var(--accent);
}
.task-check.checked {
background: var(--accent);
border-color: var(--accent);
}
.task-check.checked::after {
content: '';
position: absolute;
left: 3px;
top: 0;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.task-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.task-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
font-size: 13px;
color: var(--text-primary);
}
.task-row.done .task-label {
text-decoration: line-through;
color: var(--text-tertiary);
}
.task-label {
word-break: break-word;
}
.task-prio-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.task-prio {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 4px;
color: #fff;
border: none;
cursor: pointer;
line-height: 1.5;
}
.task-prio.prio-none {
background: transparent;
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-prio.prio-none { opacity: 1; }
.prio-high { background: #ef4444; }
.prio-med { background: #f59e0b; }
.prio-low { background: #64748b; }
.prio-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 3px;
z-index: 51;
display: flex;
flex-direction: column;
min-width: 84px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
overflow: hidden;
padding: 3px;
gap: 2px;
}
.prio-opt {
text-align: left;
padding: 4px 8px;
font-size: 12px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
}
.prio-opt:hover { background: var(--bg-hover); }
.prio-opt.prio-high { color: #ef4444; background: transparent; }
.prio-opt.prio-med { color: #f59e0b; background: transparent; }
.prio-opt.prio-low { color: #64748b; background: transparent; }
.prio-opt.prio-high:hover,
.prio-opt.prio-med:hover,
.prio-opt.prio-low:hover { background: var(--bg-hover); }
.prio-opt.prio-clear { color: var(--text-tertiary); }
.prio-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: transparent;
border: none;
padding: 0;
cursor: default;
}
.task-due-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.task-due {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
cursor: pointer;
font-family: inherit;
line-height: 1.5;
}
.task-due:not(.set) {
background: transparent;
border-style: dashed;
color: var(--text-tertiary);
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-due:not(.set) { opacity: 1; }
.task-due.overdue {
color: #ef4444;
border-color: #ef4444;
}
.task-due.duetoday {
color: #f59e0b;
border-color: #f59e0b;
}
.due-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 3px;
z-index: 51;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
padding: 6px;
}
.due-input {
font-family: inherit;
font-size: 12px;
padding: 3px 5px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
color-scheme: light dark;
}
.task-note {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Calendar */
.tasks-cal-pane {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.cal {
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.cal-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.cal-nav-btn:hover { background: var(--bg-hover); }
.cal-title {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
}
.cal-title:hover { background: var(--bg-hover); }
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.cal-head {
text-align: center;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
padding: 2px 0;
}
.cal-cell {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.cal-cell.empty { cursor: default; }
.cal-cell:not(.empty):hover { background: var(--bg-hover); }
.cal-cell.today { border-color: var(--accent); color: var(--accent); font-weight: 700; }
.cal-cell.active { background: var(--accent); color: #fff; }
.cal-cell.drop-over {
border: 1px dashed var(--accent);
background: var(--bg-hover);
}
.cal-count {
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.cal-count.overdue { background: #ef4444; }
.cal-cell.active .cal-count { background: rgba(255, 255, 255, 0.3); }
.cal-agenda {
flex: 1;
}
.agenda-section {
border-bottom: 1px solid var(--border-color);
padding-bottom: 4px;
}
.agenda-head {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-tertiary);
}
.agenda-head.overdue { color: #ef4444; }
.tasks-empty-sm {
padding: 6px 12px 10px;
font-size: 12px;
color: var(--text-tertiary);
}
</style>
+9 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { getCurrentWindow } from '@tauri-apps/api/window';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab } from '$lib/stores/app';
import { vaultReady, focusMode, readOnly, updateAvailable, showSettings, settingsTab, appConfig, syncState } from '$lib/stores/app';
import { syncNow } from '$lib/api';
let { onNewNote = () => {}, onDailyNote = () => {} }: {
onNewNote?: () => void;
@@ -111,6 +112,13 @@
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
{#if $appConfig?.sync_provider === 'webdav'}
<button class="switch-vault-btn" class:active={$syncState.running} onclick={() => { if (!$syncState.running) syncNow().catch(() => {}); }} disabled={$syncState.running} title={$syncState.error ? `Sync error: ${$syncState.error}` : ($syncState.running ? 'Syncing vault...' : 'Sync vault now')}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class:sync-spin={$syncState.running}>
<path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0115-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 01-15 6.7L3 16"/>
</svg>
</button>
{/if}
<button class="new-note-btn" onclick={onNewNote} title={`New Note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" />
+97 -14
View File
@@ -1,20 +1,22 @@
<script lang="ts">
import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { openVault, getAppConfig } from '$lib/api';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, removeVault, getAppConfig } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types';
const appWindow = getCurrentWindow();
const isMobile = /android|ios/i.test(navigator.userAgent);
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
let loading = $state(false);
let error = $state('');
let vaultName = $state('HelixNotes');
let hasPermission = $state(!isMobile);
let hasPermission = $state(!isAndroid);
let selectedLocation = $state('Documents');
let customPath = $state('');
let iosBasePath = $state('');
const storageLocations = [
{ label: 'Documents', path: '/storage/emulated/0/Documents' },
@@ -22,7 +24,7 @@
{ label: 'Internal Storage', path: '/storage/emulated/0' },
];
if (isMobile) {
if (isAndroid) {
checkPermission();
setTimeout(checkPermission, 500);
(window as any).__storagePermissionGranted = () => { hasPermission = true; };
@@ -30,6 +32,10 @@
if (document.visibilityState === 'visible') setTimeout(checkPermission, 300);
});
}
if (isIOS) {
// iOS is sandboxed: the vault lives in the app's Documents dir (exposed via the Files app).
documentDir().then((d) => { iosBasePath = d.replace(/\/+$/, ''); }).catch(() => {});
}
function checkPermission() {
try {
@@ -56,10 +62,17 @@
return storageLocations.find(l => l.label === selectedLocation)?.path || '/storage/emulated/0/Documents';
}
let fullPath = $derived(isMobile ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}` : '');
let fullPath = $derived(
isIOS ? `${iosBasePath}/${vaultName.trim() || 'HelixNotes'}`
: isAndroid ? `${getMobileBasePath()}/${vaultName.trim() || 'HelixNotes'}`
: ''
);
async function pickFolder() {
if (isMobile) {
if (isIOS) {
const base = iosBasePath || (await documentDir()).replace(/\/+$/, '');
await openSelectedVault(`${base}/${vaultName.trim() || 'HelixNotes'}`);
} else if (isAndroid) {
await openSelectedVault(fullPath);
} else {
const selected = await open({ directory: true, multiple: false, title: 'Choose Notes Folder' });
@@ -73,7 +86,7 @@
loading = true;
error = '';
try {
if (isMobile) {
if (isAndroid) {
try {
const bridge = (window as any).Android;
bridge?.prepareVaultDir?.(path);
@@ -84,11 +97,25 @@
$appConfig = config;
$vaultReady = true;
} catch (e) {
error = String(e);
const msg = String(e);
if (isAndroid && /os error 13|permission denied/i.test(msg)) {
error = 'Storage permission is required. Enable file access for HelixNotes in your system settings, then try again.';
} else {
error = msg;
}
} finally {
loading = false;
}
}
async function forgetVault(path: string) {
try {
await removeVault(path);
$appConfig = await getAppConfig();
} catch (e) {
error = String(e);
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -118,15 +145,20 @@
<h1>HelixNotes</h1>
<p class="subtitle">Local markdown notes</p>
{#if isMobile}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{#if isIOS}
<p class="description">Your notes are saved in the app's folder and appear in the Files app under "On My iPhone &rarr; HelixNotes".</p>
{:else}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{/if}
{#if !hasPermission}
{#if isAndroid && !hasPermission}
<button class="btn-permission" onclick={requestPermission}>
Grant File Access
</button>
<p class="permission-hint">Required to access Documents, Downloads, and other shared folders.</p>
{/if}
{#if isAndroid}
<div class="location-selector">
<label class="location-label">Location</label>
<div class="location-options">
@@ -147,6 +179,7 @@
<input class="custom-path-input" type="text" bind:value={customPath} placeholder="/storage/emulated/0/..." />
{/if}
</div>
{/if}
<div class="vault-name-input">
<label for="vault-name">Vault name</label>
@@ -181,10 +214,18 @@
<div class="recent">
<span class="recent-label">Recent</span>
{#each recentVaults as vault}
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}</span>
<span class="vault-path">{vault.path}</span>
</button>
<div class="vault-row">
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}</span>
<span class="vault-path">{vault.path}</span>
</button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
<svg width="12" height="12" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
@@ -365,6 +406,48 @@
white-space: nowrap;
}
.vault-row {
display: flex;
align-items: stretch;
gap: 6px;
margin-bottom: 6px;
}
.vault-row .vault-item {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.vault-remove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.vault-row:hover .vault-remove,
.vault-remove:focus-visible {
opacity: 1;
}
.vault-remove:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-picker.mobile .vault-remove {
opacity: 1;
}
.vault-name-input {
margin-bottom: 20px;
text-align: left;
@@ -0,0 +1,30 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { getSelectionPair } from '../selectionPairs';
// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection
// without flattening selected inline marks or content to plain text.
export const WrapSelectedText = Extension.create({
name: 'wrapSelectedText',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('wrapSelectedText'),
props: {
// Intercept only supported single-character text input over a range.
// Everything else falls back to ProseMirror's normal input handling.
handleTextInput(view, from, to, text) {
const close = getSelectionPair(text);
if (!close || from === to) return false;
const tr = view.state.tr.insertText(close, to).insertText(text, from);
tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
view.dispatch(tr);
return true;
},
},
}),
];
},
});
+15
View File
@@ -0,0 +1,15 @@
// Shared source of truth for editor behaviors that wrap an active selection.
// Keep this small: only characters users reasonably expect to form pairs.
const selectionPairs: Record<string, string> = {
'(': ')',
'{': '}',
'[': ']',
"'": "'",
'"': '"',
'`': '`',
};
// Returns the closing character for a typed wrapper key, or null to pass through.
export function getSelectionPair(key: string) {
return selectionPairs[key] ?? null;
}
+14
View File
@@ -0,0 +1,14 @@
import { getSelectionPair } from '../selectionPairs';
// Pure source-mode helper: computes the wrapped textarea value and preserved
// inner selection so Svelte event handlers stay as glue code.
export function wrapTextareaSelection(value: string, start: number, end: number, key: string) {
const close = getSelectionPair(key);
if (!close || start === end) return null;
return {
value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end),
selectionStart: start + key.length,
selectionEnd: end + key.length,
};
}
+7
View File
@@ -0,0 +1,7 @@
// Centralized platform detection. iOS WebViews report iPhone/iPad/iPod in the user
// agent (not the literal "ios"), so they must be matched explicitly.
const ua = typeof navigator !== 'undefined' ? navigator.userAgent : '';
export const isAndroid = /android/i.test(ua);
export const isIOS = /iphone|ipad|ipod/i.test(ua);
export const isMobile = isAndroid || isIOS;
+5
View File
@@ -0,0 +1,5 @@
// Ambient type shims for markdown-it plugins that ship without their own TypeScript
// declarations. Type-only; no runtime effect.
declare module 'markdown-it-mark';
declare module 'markdown-it-sub';
declare module 'markdown-it-sup';
+25
View File
@@ -16,6 +16,10 @@ export const vaultReady = writable(false);
// UI state
export const viewMode = writable<ViewMode>("all");
export const sortMode = writable<SortMode>("modified");
export const tasksLayout = writable<"list" | "calendar">("list");
export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false);
export const tasksSort = writable<"due" | "priority" | "note">("due");
export const sidebarCollapsed = writable(false);
export const sidebarWidth = writable(220);
export const notelistWidth = writable(280);
@@ -67,6 +71,13 @@ export const readOnly = writable(false);
// Theme
export const theme = writable<string>("system");
// Sync (WebDAV) - global status so the top-bar button reflects any sync,
// whoever triggered it (manual button, settings, interval, on-change).
export const syncState = writable<{ running: boolean; error: string | null }>({
running: false,
error: null,
});
// Update state
export const updateAvailable = writable<{
version: string;
@@ -74,8 +85,22 @@ export const updateAvailable = writable<{
} | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native");
// True only on the Android/iOS build. Defaults to a user-agent guess for the first paint, then
// gets the authoritative compile-time value from the backend at startup (see +layout). (#63)
export const platformIsMobile = writable<boolean>(
typeof navigator !== "undefined" && /android|iphone|ipad|ipod/i.test(navigator.userAgent),
);
export const androidApkUrl = writable<string | null>(null);
// Install types that handle their own updates (in-app auto-updater, or a
// package-manager notice for deb/aur). Anything else - e.g. a distro repo build
// that sets the HELIXNOTES_INSTALL_TYPE build flag (Solus, etc.) - is "managed":
// the app does no update check and shows no update UI at all.
const SELF_UPDATING_INSTALL_TYPES = ["macos", "windows", "deb", "aur", "appimage", "native", "android"];
export function isManagedInstall(type: string): boolean {
return !SELF_UPDATING_INSTALL_TYPES.includes(type);
}
export async function checkForUpdate() {
try {
const { check } = await import("@tauri-apps/plugin-updater");
+39 -1
View File
@@ -54,14 +54,19 @@ export interface AppConfig {
font_size: number | null;
font_family: string | null;
line_height: number | null;
ui_scale: number | null;
compact_notes: boolean;
show_note_dates: boolean;
time_format: string;
week_start: string;
daily_title_format: string;
gpu_acceleration: boolean;
autostart: boolean;
pdf_preview: boolean;
pdf_height: number;
title_mode: string;
hide_title_in_body: boolean;
show_line_numbers: boolean;
backup_enabled: boolean;
backup_frequency: string;
backup_max_count: number;
@@ -73,11 +78,24 @@ export interface AppConfig {
ai_api_key: string | null;
openai_api_key: string | null;
ollama_base_url: string | null;
ollama_api_key: string | null;
openai_compatible_base_url: string | null;
openai_compatible_api_key: string | null;
ai_model: string;
ai_writing_style: string | null;
default_view_mode: boolean;
show_tray_icon: boolean;
close_to_tray: boolean;
enable_wiki_links: boolean;
restore_last_session: boolean;
sync_provider: string | null;
webdav_url: string | null;
webdav_username: string | null;
webdav_password: string | null;
sync_on_open: boolean;
sync_on_change: boolean;
sync_interval_minutes: number;
last_sync_time: string | null;
}
export interface VaultState {
@@ -88,6 +106,14 @@ export interface VaultState {
collapsed_notebooks: string[];
notebook_sort_mode?: string;
notebook_order?: Record<string, number>;
sort_mode?: string;
last_view_mode?: string;
last_notebook?: string | null;
last_tag?: string | null;
tasks_layout?: string;
tasks_hide_completed?: boolean;
tasks_only_flagged?: boolean;
tasks_sort?: string;
}
export interface SearchResult {
@@ -146,4 +172,16 @@ export type ViewMode =
| "trash"
| "search"
| "quickaccess"
| "daily";
| "daily"
| "tasks";
export interface TaskItem {
note_path: string;
note_title: string;
line: number;
raw_line: string;
text: string;
completed: boolean;
due: string | null;
priority: string | null;
}
+150
View File
@@ -0,0 +1,150 @@
const SECRET_VERSION = 2;
const SECRET_CIPHER = 'AES-256-GCM';
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
const SECRET_ITERATIONS = 600_000;
const SALT_BYTES = 16;
const NONCE_BYTES = 12;
interface SecretEnvelope {
v: number;
title?: string;
cipher: string;
kdf: string;
iterations: number;
salt: string;
nonce: string;
ct: string;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string, field: string): Uint8Array {
const text = value.trim();
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
throw new Error(`Invalid secret ${field}`);
}
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
function authenticatedMetadata(envelope: SecretEnvelope): string {
return JSON.stringify({
v: envelope.v,
title: envelope.title ?? 'Encrypted secret',
cipher: envelope.cipher,
kdf: envelope.kdf,
iterations: envelope.iterations,
salt: envelope.salt,
nonce: envelope.nonce,
});
}
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
return encoder.encode(authenticatedMetadata(envelope));
}
function parseEnvelope(payload: string): SecretEnvelope {
let envelope: SecretEnvelope;
try {
envelope = JSON.parse(payload);
} catch {
throw new Error('Invalid secret payload');
}
if (
envelope?.v !== SECRET_VERSION ||
envelope.cipher !== SECRET_CIPHER ||
envelope.kdf !== SECRET_KDF ||
!Number.isInteger(envelope.iterations) ||
envelope.iterations < 100_000 ||
envelope.iterations > 5_000_000
) {
throw new Error('Unsupported secret payload');
}
return envelope;
}
export function readSecretTitle(payload: string): string {
try {
const parsed = JSON.parse(payload);
return typeof parsed?.title === 'string' && parsed.title.trim()
? parsed.title.trim()
: 'Encrypted secret';
} catch {
return 'Encrypted secret';
}
}
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
if (!passphrase) throw new Error('Passphrase is required');
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
if (!plaintext) throw new Error('Secret text is required');
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
const envelope: SecretEnvelope = {
v: SECRET_VERSION,
title: title.trim() || 'Encrypted secret',
cipher: SECRET_CIPHER,
kdf: SECRET_KDF,
iterations: SECRET_ITERATIONS,
salt: bytesToBase64(salt),
nonce: bytesToBase64(nonce),
ct: '',
};
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
encoder.encode(plaintext),
);
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
return JSON.stringify(envelope, null, 2);
}
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
try {
const envelope = parseEnvelope(payload);
const salt = base64ToBytes(envelope.salt, 'salt');
const nonce = base64ToBytes(envelope.nonce, 'nonce');
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
const key = await deriveKey(passphrase, salt, envelope.iterations);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
stableBytes(ciphertext),
);
return decoder.decode(plaintext);
} catch {
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
}
}
+44 -12
View File
@@ -1,26 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
let { children } = $props();
const isMobile = /android|ios/i.test(navigator.userAgent);
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
// Reactively apply theme class to <html> whenever $theme changes
$effect(() => {
const t = $theme;
applyTheme($theme);
});
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
if (
t === 'dark' ||
(t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
) {
root.removeAttribute('data-theme');
if (namedThemes.includes(t)) {
root.setAttribute('data-theme', t);
if (darkThemes.includes(t)) root.classList.add('dark');
} else if (t === 'dark' || (t === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
});
}
function normalizePath(p: string): string {
const parts = p.split('/');
@@ -46,7 +53,7 @@
}
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
@@ -81,12 +88,16 @@
// Detect install type and check for updates on startup
onMount(() => {
// Authoritative platform from the backend (compile-time), overriding the UA guess. (#63)
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
if (isMobile) {
installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
getInstallType().then(t => {
installType.set(t);
if (!isManagedInstall(t)) checkForUpdate();
}).catch(() => {});
}
});
@@ -160,6 +171,25 @@
}
};
});
// On Windows the native OS drag-drop handler is disabled (dragDropEnabled:false
// in tauri.windows.conf.json) so HTML5 drag-and-drop works for reordering. With it
// off, a file dropped outside a drop zone would make the webview navigate to / open
// that file, replacing the app. Swallow any drag that bubbles up unhandled. Real drop
// zones (editor, sidebar reordering) call preventDefault in their own handlers first;
// these bubble-phase listeners only act as a fallback. On macOS/Linux OS file drops are
// intercepted natively and never surface as HTML5 events, so this is a harmless no-op there.
onMount(() => {
function preventNavigate(e: DragEvent) {
e.preventDefault();
}
window.addEventListener('dragover', preventNavigate);
window.addEventListener('drop', preventNavigate);
return () => {
window.removeEventListener('dragover', preventNavigate);
window.removeEventListener('drop', preventNavigate);
};
});
</script>
<svelte:document oncontextmenu={(e) => { if (!isMobile) e.preventDefault(); }} />
@@ -169,3 +199,5 @@
</svelte:head>
{@render children()}
<ResizeHandles />
+144 -18
View File
@@ -1,13 +1,108 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault } from '$lib/api';
import { getAppConfig, openVault, setFontSize } from '$lib/api';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte';
import NoteWindow from '$lib/components/NoteWindow.svelte';
let loading = $state(true);
let noteWindowPath = $state<string | null>(null);
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
const defaultEditorFontSize = 14;
const minEditorFontSize = 10;
const maxEditorFontSize = 32;
function getEditorScrollSurface(): HTMLElement | HTMLTextAreaElement | null {
const sourceEditor = document.querySelector<HTMLTextAreaElement>('.source-editor');
if (sourceEditor && getComputedStyle(sourceEditor).display !== 'none') return sourceEditor;
return document.querySelector<HTMLElement>('.editor-body');
}
function preserveEditorViewportAfterLayout(scrollSurface: HTMLElement | HTMLTextAreaElement | null) {
if (!scrollSurface) return () => {};
const isSourceEditor = scrollSurface instanceof HTMLTextAreaElement;
const maxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
const scrollRatio = maxScroll > 0 ? scrollSurface.scrollTop / maxScroll : 0;
const rect = scrollSurface.getBoundingClientRect();
const anchorY = Math.min(rect.bottom - 1, rect.top + Math.max(16, rect.height * 0.25));
let anchorElement: HTMLElement | null = null;
let anchorOffset = 0;
if (!isSourceEditor) {
const candidate = document.elementFromPoint(rect.left + Math.min(80, rect.width / 2), anchorY);
anchorElement = candidate?.closest('.tiptap *') as HTMLElement | null;
if (anchorElement) anchorOffset = anchorElement.getBoundingClientRect().top - rect.top;
}
return () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (anchorElement && scrollSurface.contains(anchorElement)) {
const nextRect = scrollSurface.getBoundingClientRect();
scrollSurface.scrollTop += anchorElement.getBoundingClientRect().top - nextRect.top - anchorOffset;
} else {
const nextMaxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
scrollSurface.scrollTop = Math.max(0, nextMaxScroll * scrollRatio);
}
});
});
};
}
function setEditorFontSize(value: number) {
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
if ($appConfig) $appConfig.font_size = nextSize;
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
restoreEditorViewport();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
fontSizeSaveTimer = setTimeout(() => {
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
fontSizeSaveTimer = null;
}, 150);
}
function installEditorZoomShortcuts() {
const zoomEditor = (delta: number) => setEditorFontSize(($appConfig?.font_size ?? defaultEditorFontSize) + delta);
const handleKeydown = (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
if (event.altKey || event.shiftKey) return;
if (event.key === '+' || event.key === '=') {
event.preventDefault();
event.stopPropagation();
zoomEditor(1);
} else if (event.key === '-' || event.key === '_') {
event.preventDefault();
event.stopPropagation();
zoomEditor(-1);
} else if (event.key === '0') {
event.preventDefault();
event.stopPropagation();
setEditorFontSize(defaultEditorFontSize);
}
};
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('wheel', handleWheel, { capture: true });
};
}
onMount(async () => {
// Check for note window mode
@@ -25,8 +120,14 @@
// Apply theme immediately to prevent flash
const themeValue = config.theme || 'system';
const root = document.documentElement;
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
root.classList.remove('dark');
if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.removeAttribute('data-theme');
if (namedThemes.includes(themeValue)) {
root.setAttribute('data-theme', themeValue);
if (darkThemes.includes(themeValue)) root.classList.add('dark');
} else if (themeValue === 'dark' || (themeValue === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)) {
root.classList.add('dark');
}
@@ -53,21 +154,31 @@
}
// Apply saved accent
if (config.accent_color) {
const accentPresets: Record<string, { light: string; dark: string }> = {
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
Rose: { light: '#e11d48', dark: '#d4768a' },
Emerald: { light: '#059669', dark: '#5bab8a' },
Amber: { light: '#d97706', dark: '#c9a04e' },
Purple: { light: '#7c3aed', dark: '#9a82c4' },
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
Orange: { light: '#ea580c', dark: '#c98560' },
Teal: { light: '#0d9488', dark: '#5fada5' },
};
const preset = accentPresets[config.accent_color];
if (preset) {
const themeVal = config.theme || 'light';
const isDark = themeVal === 'dark' || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const color = isDark ? preset.dark : preset.light;
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
let color: string | null = null;
if (config.accent_color.startsWith('#')) {
color = config.accent_color;
} else {
const accentPresets: Record<string, { light: string; dark: string }> = {
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
Rose: { light: '#e11d48', dark: '#d4768a' },
Emerald: { light: '#059669', dark: '#5bab8a' },
Amber: { light: '#d97706', dark: '#c9a04e' },
Purple: { light: '#7c3aed', dark: '#9a82c4' },
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
Orange: { light: '#ea580c', dark: '#c98560' },
Teal: { light: '#0d9488', dark: '#5fada5' },
Sakura: { light: '#d4547a', dark: '#f4a0b5' },
Gold: { light: '#b8860b', dark: '#fabd2f' },
Lavender: { light: '#9d5cdb', dark: '#c084fc' },
Sky: { light: '#0284c7', dark: '#38bdf8' },
Coral: { light: '#e85d4a', dark: '#ff8a7a' },
};
const preset = accentPresets[config.accent_color];
if (preset) color = isDark ? preset.dark : preset.light;
}
if (color) {
const root = document.documentElement;
root.style.setProperty('--accent', color);
root.style.setProperty('--text-accent', color);
@@ -78,6 +189,16 @@
}
}
// Apply saved interface scale (desktop main window only; no-ops gracefully elsewhere)
if (!noteWindowPath && config.ui_scale && config.ui_scale !== 1) {
try {
await getCurrentWebview().setZoom(config.ui_scale);
} catch (e) {
console.error('Failed to apply interface scale:', e);
}
}
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
// Auto-open last vault if available
if (config.active_vault) {
try {
@@ -95,6 +216,11 @@
}
loading = false;
});
onDestroy(() => {
removeEditorZoomShortcuts?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
});
</script>
{#if loading}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.

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