Compare commits

...
49 Commits
Author SHA1 Message Date
Yuri Karamian d497ff8aaf Add iOS Files vault selection 2026-07-24 02:19:00 +02:00
Yuri Karamian 51acd51cb2 Expose iOS vault in Files 2026-07-24 00:44:15 +02:00
Yuri Karamian b4bb3b89d6 Update project links for GitLab migration 2026-07-23 17:58:44 +02:00
Yuri Karamian 9b51aef073 Reveal selected tasks in editor 2026-07-20 13:09:35 +02:00
Yuri Karamian 4aa0b200b9 Fix Windows relative image resolution (#215) 2026-07-20 02:47:11 +02:00
Yuri Karamian 8769b56fd1 Fix strikethrough keyboard shortcut (#205) 2026-07-20 02:42:32 +02:00
Yuri Karamian 06d2cd744f Refine scrollbars and editor spacing (#209) 2026-07-20 02:30:29 +02:00
Yuri Karamian 7428cc7dfb shorten comments 2026-07-20 02:20:14 +02:00
Yuri Karamian 6c89d668fe Fix table sizing and headerless table persistence (#213) 2026-07-20 02:17:38 +02:00
Yuri Karamian 2c76a4747e Fix mobile notebook navigation and sidebar clipping (#220) 2026-07-20 02:08:36 +02:00
Yuri Karamian fd55c00015 Fix note unfiling and relative image paths (#222) 2026-07-20 02:03:15 +02:00
Yuri Karamian 28ef37f354 Fix mobile safe-area layout 2026-07-15 12:00:54 +02:00
Chris Paganon bf5a9f134d fix(editor): refocus open in-note search on Ctrl+F (#216)
Pressing ctrl-F doesn’t refocus the search field if there is already a search term, it only focuses search on 1st open of the search field. This PR fixes it so that every time the user presses CTRL+F, the search field is focused again.

Tested on linux.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/216
2026-07-14 18:36:34 +02:00
Yuri Karamian 2991940ba8 fix: remove transparency from iOS app icons 2026-07-13 20:43:23 +02:00
Yuri Karamian 54352a03d8 Mermaid diagram "Copy failed: The operation is insecure" on Linux 2026-07-10 14:09:56 +02:00
Yuri Karamian c4d1f12b2e fix: use vault-relative paths in wikilinks for cross-device sync 2026-07-10 11:30:06 +02:00
Yuri Karamian aa699007f7 Revert "Exclude gen/ from Cargo package to fix iOS build fingerprint error"
This reverts commit 047a8b3c3d.
2026-07-09 17:08:14 +02:00
Yuri Karamian 047a8b3c3d Exclude gen/ from Cargo package to fix iOS build fingerprint error 2026-07-09 16:56:58 +02:00
Yuri Karamian 6098dd01b4 Add macOS notarization entitlements + iOS mobile guards
- Add entitlements.plist for Developer ID hardened runtime (JIT,
  unsigned memory, library validation, network client)
- Reference entitlements in tauri.conf.json bundle.macOS
- Guard get_install_type() with early mobile return (avoids
  std::process::Command in iOS binary)
- Add cfg(mobile) no-op branch to xdg_open()
2026-07-09 16:46:59 +02:00
Yuri Karamian b16beb9af5 regenerate icons set 2026-07-09 16:24:21 +02:00
mf 95565d7506 fix(editor): scale code block font size with editor zoom (#208)
Code blocks used a hardcoded 13px font-size instead of scaling with --editor-font-size, so they didn't resize with the rest of the editor content when zooming.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/208
2026-07-07 12:04:49 +02:00
lokthare a13d17ed81 Change nix formatter (#206)
the files were formatted with alejandra. the nix community has set a standard for the format and release a new tool.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/206
2026-07-06 15:53:20 +02:00
Yuri Karamian 95515cac9d Properly center macOS traffic lights via objc2 (#169) 2026-07-05 23:59:08 +02:00
Yuri Karamian 21dd2d1681 Improve sidebar notebook/unfiled icons and list indentation 2026-07-05 02:45:55 +02:00
Yuri Karamian 6d5f12c47c Improve sidebar notebook/unfiled icons and list indentation 2026-07-04 20:23:15 +02:00
Andreas Lenhardt d01bd4ea01 update max_token value in ai tests (#204)
#203

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/204
2026-07-04 14:31:07 +02:00
lokthare bd7d66b1e8 Fix nix build (#202)
fix nix build by updating flake.lock and dependencies hashes

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/202
2026-07-04 11:53:56 +02:00
Yuri Karamian 0b9fe69819 Revert PR #200 (custom theme consolidation) - broke search 2026-07-03 16:42:09 +02:00
Dmitry Rubtsov 936c38a21d Fix custom theme not applied on startup (light-theme dividers) (#200)
Launching with a custom dark theme left every divider in light-theme colors until you re-toggled the theme in Settings.

`applyTheme` was copy-pasted across the components and the copies drifted. Three of them (+page.svelte's onMount block, AppLayout.svelte, NoteWindow.svelte) had no custom-* branch, so for a custom theme they stripped the dark class and data-theme attribute, matched nothing, and fell back to the bare light :root values.

`+layout.svelte` applied the custom theme correctly, but `AppLayout` — the always-mounted shell — re-runs its own broken copy from an `$effect` on `$theme`, immediately wiping it out. Dividers gave it away because custom themes override `--border-color` but not `--border-light`, which dividers use.

Fix: consolidate into a single `applyTheme`(which already owns darkThemes/isDarkTheme) with a real `custom-*` branch, and have every call site use it and pass the loaded custom themes so `custom-*` ids resolve.

Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/200
2026-07-03 13:34:11 +02:00
Yuri Karamian e60e637437 Fix macOS traffic light vertical centering (#169) 2026-06-30 22:16:19 +02:00
Yuri Karamian 479c957698 Robust Obsidian importer: frontmatter normalization, syntax conversion, deprecated property renaming, same-note heading links 2026-06-30 22:10:22 +02:00
Yuri Karamian 13fd70f944 Fix Android note creation delay: throttle file watcher IPC (#191) 2026-06-30 19:48:19 +02:00
Yuri Karamian 015726f896 Add inline restore/delete icons on trashed notes (#188) 2026-06-30 16:41:06 +02:00
Yuri Karamian 5e2c31598c Block note creation in Quick Access/Trash, add icons to trash actions (#188) 2026-06-30 16:13:22 +02:00
Yuri Karamian 24d1ba9f8f Block note creation in Quick Access and Trash views (#188) 2026-06-30 15:59:35 +02:00
Yuri Karamian 5345442de2 Auto-cleanup orphaned attachments on vault open (#190) 2026-06-30 15:54:37 +02:00
Yuri Karamian 6ea2ca0ae2 Keep textarea selection visible when opening AI menu in source mode 2026-06-30 14:40:28 +02:00
Yuri Karamian 6f4f526d07 Fix OpenAI test using max_tokens for GPT-5 models 2026-06-30 14:36:20 +02:00
Yuri Karamian 13bad87500 Fix AI apply-to-note in source mode (#189) 2026-06-30 14:29:44 +02:00
Yuri Karamian acecc9c68d Fix callout body paragraphs merging on reload (#192) 2026-06-30 14:09:25 +02:00
Yuri Karamian 35a8501f2d Fix mobile popup positioning using visualViewport (#193) 2026-06-30 12:51:41 +02:00
Yuri Karamian 09f9df968d Render callouts live on Enter and paste in WYSIWYG (#186) 2026-06-30 12:37:14 +02:00
Yuri Karamian 2c58bf3d9a Fix empty lines round-trip and trim trailing markers (#185) 2026-06-30 11:01:25 +02:00
Yuri Karamian e063f1c73a Clamp formatting-bar dropdown menus into the viewport (#178) 2026-06-28 19:36:16 +02:00
Yuri Karamian cafc0ce1ec Reveal search result in its notebook in the notes list 2026-06-28 19:21:14 +02:00
Yuri Karamian a192723fbd Refresh outline panel when switching notes (#184) 2026-06-28 19:12:29 +02:00
Yuri Karamian b643eeb884 Gate set_mobile_config_dir behind cfg(mobile) to fix desktop dead-code warning (#183) 2026-06-28 19:08:01 +02:00
Yuri Karamian eb042e5760 Tolerate HTTP 403 in MKCOL for non-compliant WebDAV servers (#91) 2026-06-28 19:04:52 +02:00
Yuri Karamian 6251d96193 docs: bump README download links to 1.3.3 2026-06-28 14:49:54 +02:00
89 changed files with 2959 additions and 831 deletions
+12 -12
View File
@@ -1,7 +1,7 @@
# HelixNotes # 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) [![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.3.2-green)](https://codeberg.org/ArkHost/HelixNotes/releases) [![Latest Release](https://img.shields.io/badge/release-v1.3.3-green)](https://gitlab.com/ArkHost/HelixNotes/-/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com) [![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)]() [![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. Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in. No cloud, no lock-in.
## Download (v1.3.2) ## Download (v1.3.3)
### Linux ### Linux
@@ -42,7 +42,7 @@ sudo eopkg it helixnotes
inputs = { inputs = {
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable"; nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
helix-notes = { helix-notes = {
url = "git+https://codeberg.org/ArkHost/HelixNotes"; url = "git+https://gitlab.com/ArkHost/HelixNotes";
# inputs.nixpkgs.follows = "nixpkgs"; # inputs.nixpkgs.follows = "nixpkgs";
} }
}; };
@@ -92,21 +92,21 @@ sudo eopkg it helixnotes
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed) #### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.2/HelixNotes_1.3.2_amd64.AppImage) [Download AppImage](https://download.helixnotes.com/releases/v1.3.3/HelixNotes_1.3.3_amd64.AppImage)
#### .deb (manual) #### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_amd64.deb) (Ubuntu 22.04+) [Download .deb](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_amd64.deb) (Ubuntu 22.04+)
### Windows ### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64-setup.exe) (Windows 10/11) [Download Installer](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64-setup.exe) (Windows 10/11)
### macOS ### macOS
[Download .dmg (Apple Silicon)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_aarch64.dmg) (M-series Macs) [Download .dmg (Apple Silicon)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_aarch64.dmg) (M-series Macs)
[Download .dmg (Intel)](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_x64.dmg) (older Macs, or Apple Silicon via Rosetta) [Download .dmg (Intel)](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_x64.dmg) (older Macs, or Apple Silicon via Rosetta)
> **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update): > **"HelixNotes is damaged and can't be opened"?** The app isn't damaged. The macOS build isn't notarized by Apple yet, so Gatekeeper blocks it on Apple Silicon. Run this once in Terminal, then open it normally (you'll need to redo it after each update):
> >
@@ -116,11 +116,11 @@ sudo eopkg it helixnotes
### Android ### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.2/HelixNotes_1.3.2_android.apk) [Download APK](https://gitlab.com/ArkHost/HelixNotes/-/releases/v1.3.3/downloads/HelixNotes_1.3.3_android.apk)
--- ---
All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/ArkHost/HelixNotes/releases) All releases: [gitlab.com/ArkHost/HelixNotes/-/releases](https://gitlab.com/ArkHost/HelixNotes/-/releases)
## Features ## Features
@@ -183,4 +183,4 @@ pnpm tauri build
## License ## License
[AGPL-3.0](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE) [AGPL-3.0](https://gitlab.com/ArkHost/HelixNotes/-/blob/main/LICENSE)
Generated
+3 -3
View File
@@ -20,11 +20,11 @@
}, },
"nixpkgs": { "nixpkgs": {
"locked": { "locked": {
"lastModified": 1777268161, "lastModified": 1782959384,
"narHash": "sha256-bxrdOn8SCOv8tN4JbTF/TXq7kjo9ag4M+C8yzzIRYbE=", "narHash": "sha256-xnJJk+ct+D2+wdRxj1wk36w5zV9RVESwRqcklPdt3fM=",
"owner": "NixOS", "owner": "NixOS",
"repo": "nixpkgs", "repo": "nixpkgs",
"rev": "1c3fe55ad329cbcb28471bb30f05c9827f724c76", "rev": "65179426c83bb3f6bc14898b42ea1c6f01d374b0",
"type": "github" "type": "github"
}, },
"original": { "original": {
+60 -54
View File
@@ -6,71 +6,77 @@
flake-utils.url = "github:numtide/flake-utils"; flake-utils.url = "github:numtide/flake-utils";
}; };
outputs = { outputs =
self, {
nixpkgs, self,
flake-utils, nixpkgs,
}: flake-utils,
flake-utils.lib.eachDefaultSystem (system: let }:
pkgs = import nixpkgs {inherit system;}; flake-utils.lib.eachDefaultSystem (
system:
let
pkgs = import nixpkgs { inherit system; };
cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml); cargoToml = builtins.fromTOML (builtins.readFile ./src-tauri/Cargo.toml);
pname = cargoToml.package.name; pname = cargoToml.package.name;
version = cargoToml.package.version; version = cargoToml.package.version;
in { in
packages.default = pkgs.rustPlatform.buildRustPackage { {
inherit pname version; packages.default = pkgs.rustPlatform.buildRustPackage {
src = ./.;
cargoHash = "sha256-3ihGgdU/1WFa3aOeEi5AcwTFIyGQgxT7Ad0uVuFYB+4=";
pnpmDeps = pkgs.fetchPnpmDeps {
inherit pname version; inherit pname version;
src = ./.; src = ./.;
fetcherVersion = 3;
hash = "sha256-Odmd1gwioCvnnExvCOQwDPCgLyrBfTpeUKHy3BrNtiM=";
};
nativeBuildInputs = with pkgs; [ cargoHash = "sha256-oPShWgL5DjucCmQgqKbIBaxRwqyWnae52+Hj/YVIqbE=";
cargo-tauri.hook
pnpmConfigHook pnpmDeps = pkgs.fetchPnpmDeps {
pnpm inherit pname version;
nodejs src = ./.;
fetcherVersion = 3;
hash = "sha256-0TFLZY9zmRFNHPFJEiU2U8zMqSSPkeCl4bhORAbcdZY=";
};
pkg-config nativeBuildInputs = with pkgs; [
jq cargo-tauri.hook
moreutils
wrapGAppsHook3
];
buildInputs = with pkgs; pnpmConfigHook
lib.optionals stdenv.hostPlatform.isLinux [ pnpm
webkitgtk_4_1 nodejs
libayatana-appindicator
pkg-config
jq
moreutils
wrapGAppsHook3
]; ];
cargoRoot = "src-tauri"; buildInputs =
buildAndTestSubdir = self.packages.${system}.default.cargoRoot; with pkgs;
lib.optionals stdenv.hostPlatform.isLinux [
webkitgtk_4_1
libayatana-appindicator
];
# Deactivate the upstream update mechanism cargoRoot = "src-tauri";
postPatch = '' buildAndTestSubdir = self.packages.${system}.default.cargoRoot;
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; { # Deactivate the upstream update mechanism
description = "HelixNotes desktop application"; postPatch = ''
homepage = "https://helixnotes.com/"; jq '
license = licenses.agpl3Plus; .bundle.createUpdaterArtifacts = false |
platforms = platforms.linux ++ platforms.darwin; .plugins.updater = {"active": false, "pubkey": "", "endpoints": []}
mainProgram = "helixnotes"; ' \
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; formatter = pkgs.nixfmt;
}); }
);
} }
+10
View File
@@ -1876,6 +1876,7 @@ dependencies = [
"tauri-build", "tauri-build",
"tauri-plugin-dialog", "tauri-plugin-dialog",
"tauri-plugin-fs", "tauri-plugin-fs",
"tauri-plugin-ios-vault-access",
"tauri-plugin-log", "tauri-plugin-log",
"tauri-plugin-opener", "tauri-plugin-opener",
"tauri-plugin-single-instance", "tauri-plugin-single-instance",
@@ -5201,6 +5202,15 @@ dependencies = [
"url", "url",
] ]
[[package]]
name = "tauri-plugin-ios-vault-access"
version = "0.1.0"
dependencies = [
"serde",
"tauri",
"tauri-plugin",
]
[[package]] [[package]]
name = "tauri-plugin-log" name = "tauri-plugin-log"
version = "2.8.0" version = "2.8.0"
+3
View File
@@ -42,6 +42,9 @@ quick-xml = "0.36"
urlencoding = "2" urlencoding = "2"
sha2 = "0.10" sha2 = "0.10"
rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] } rustls = { version = "0.23", default-features = false, features = ["ring", "logging", "std", "tls12"] }
[target.'cfg(target_os = "ios")'.dependencies]
tauri-plugin-ios-vault-access = { path = "plugins/ios-vault-access" }
[target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies]
tauri-plugin-updater = "2" tauri-plugin-updater = "2"
tauri-plugin-single-instance = "2" tauri-plugin-single-instance = "2"
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UIFileSharingEnabled</key>
<true/>
<key>LSSupportsOpeningDocumentsInPlace</key>
<true/>
</dict>
</plist>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened runtime entitlements for Developer ID distribution (outside Mac App Store).
Required for notarization. These do NOT enable App Sandbox.
Without these, the WebKit webview and bundled Rust libraries get killed by the
hardened runtime's library validation. -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Network access for auto-updater, AI features, WebDAV sync, image proxy -->
<key>com.apple.security.network.client</key>
<true/>
</dict>
</plist>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.8 KiB

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 709 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 961 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 984 B

-9
View File
@@ -1,9 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" ry="112" fill="#626ED4"/>
<line x1="128" y1="128" x2="384" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<line x1="384" y1="128" x2="128" y2="384" stroke="#ffffff" stroke-width="8" stroke-opacity="0.5" stroke-linecap="round"/>
<circle cx="128" cy="128" r="36" fill="#ffffff"/>
<circle cx="384" cy="128" r="36" fill="#ffffff"/>
<circle cx="128" cy="384" r="36" fill="#ffffff"/>
<circle cx="384" cy="384" r="36" fill="#ffffff"/>
</svg>

Before

Width:  |  Height:  |  Size: 633 B

Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

+52
View File
@@ -0,0 +1,52 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
width="512"
height="512"
viewBox="0 0 512 512"
version="1.1"
id="svg5"
xmlns="http://www.w3.org/2000/svg"><rect
width="512"
height="512"
rx="112"
ry="112"
fill="#626ED4"
id="rect1" /><line
x1="178.45584"
y1="178.45584"
x2="333.54416"
y2="333.54416"
stroke="#ffffff"
stroke-width="25"
stroke-opacity="0.5"
stroke-linecap="round"
id="line1" /><line
x1="337.53708"
y1="174.46292"
x2="174.46294"
y2="337.53708"
stroke="#ffffff"
stroke-width="25"
stroke-opacity="0.5"
stroke-linecap="round"
id="line2" /><circle
cx="153"
cy="153"
r="36"
fill="#ffffff"
id="circle2" /><circle
cx="359"
cy="153"
r="36"
fill="#ffffff"
id="circle3" /><circle
cx="153"
cy="359"
r="36"
fill="#ffffff"
id="circle4" /><circle
cx="359"
cy="359"
r="36"
fill="#ffffff"
id="circle5" /></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 667 B

After

Width:  |  Height:  |  Size: 475 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.0 KiB

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

@@ -0,0 +1,15 @@
[package]
name = "tauri-plugin-ios-vault-access"
version = "0.1.0"
authors = ["HelixNotes"]
description = "iOS Files folder access for HelixNotes"
edition = "2021"
rust-version = "1.77.2"
links = "tauri-plugin-ios-vault-access"
[dependencies]
tauri = "2"
serde = { version = "1", features = ["derive"] }
[build-dependencies]
tauri-plugin = { version = "2", features = ["build"] }
@@ -0,0 +1,3 @@
fn main() {
tauri_plugin::Builder::new(&[]).ios_path("ios").build();
}
@@ -0,0 +1,24 @@
// swift-tools-version:5.3
import PackageDescription
let package = Package(
name: "tauri-plugin-ios-vault-access",
platforms: [.iOS(.v14)],
products: [
.library(
name: "tauri-plugin-ios-vault-access",
type: .static,
targets: ["tauri-plugin-ios-vault-access"]
)
],
dependencies: [
.package(name: "Tauri", path: "../.tauri/tauri-api")
],
targets: [
.target(
name: "tauri-plugin-ios-vault-access",
dependencies: [.byName(name: "Tauri")],
path: "Sources"
)
]
)
@@ -0,0 +1,312 @@
import Foundation
import SwiftRs
import Tauri
import UIKit
import UniformTypeIdentifiers
private struct BookmarkRequest: Decodable {
let bookmarkId: String
}
final class IosVaultAccessPlugin: Plugin, UIDocumentPickerDelegate {
private let fileManager = FileManager.default
private let lock = NSLock()
private var pickerInvoke: Invoke?
private var activeBookmarkId: String?
private var activeURL: URL?
private var stagedBookmarkId: String?
private var stagedURL: URL?
deinit {
rollbackStagedScope()
releaseCurrentScope()
}
@objc func chooseFolder(_ invoke: Invoke) {
DispatchQueue.main.async {
guard self.pickerInvoke == nil else {
invoke.reject("A folder picker is already open.")
return
}
guard let viewController = self.manager.viewController else {
self.pickerInvoke = nil
invoke.reject("Files is unavailable right now. Try again.")
return
}
self.pickerInvoke = invoke
let picker = UIDocumentPickerViewController(
forOpeningContentTypes: [.folder],
asCopy: false
)
picker.delegate = self
picker.allowsMultipleSelection = false
viewController.present(picker, animated: true)
}
}
@objc func resolveFolder(_ invoke: Invoke) throws {
let request = try invoke.parseArgs(BookmarkRequest.self)
do {
invoke.resolve(try resolveBookmark(request.bookmarkId))
} catch {
invoke.reject(message(for: error))
}
}
@objc func releaseActive(_ invoke: Invoke) {
rollbackStagedScope()
releaseCurrentScope()
invoke.resolve()
}
@objc func commitStaged(_ invoke: Invoke) {
commitStagedScope()
invoke.resolve()
}
@objc func rollbackStaged(_ invoke: Invoke) {
rollbackStagedScope()
invoke.resolve()
}
@objc func forgetBookmark(_ invoke: Invoke) throws {
let request = try invoke.parseArgs(BookmarkRequest.self)
do {
let file = try bookmarkFile(for: request.bookmarkId, createDirectory: false)
lock.lock()
let isActive = activeBookmarkId == request.bookmarkId
let isStaged = stagedBookmarkId == request.bookmarkId
lock.unlock()
if fileManager.fileExists(atPath: file.path) {
try fileManager.removeItem(at: file)
}
if isActive {
releaseCurrentScope()
}
if isStaged {
rollbackStagedScope()
}
invoke.resolve()
} catch {
invoke.reject(message(for: error))
}
}
func documentPicker(
_ controller: UIDocumentPickerViewController,
didPickDocumentsAt urls: [URL]
) {
guard let invoke = takePickerInvoke() else { return }
guard let url = urls.first else {
invoke.resolve(cancelledResult())
return
}
do {
guard url.startAccessingSecurityScopedResource() else {
throw AccessError.denied
}
var adopted = false
defer {
if !adopted { url.stopAccessingSecurityScopedResource() }
}
let name = try validateFolder(url)
let bookmarkId = existingBookmarkId(for: url) ?? UUID().uuidString.lowercased()
let bookmark = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
try bookmark.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
stageScope(url: url, bookmarkId: bookmarkId)
adopted = true
invoke.resolve(result(bookmarkId: bookmarkId, url: url, name: name))
} catch {
invoke.reject(message(for: error))
}
}
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
takePickerInvoke()?.resolve(cancelledResult())
}
private func resolveBookmark(_ bookmarkId: String) throws -> [String: Any] {
guard UUID(uuidString: bookmarkId) != nil else { throw AccessError.missing }
lock.lock()
let currentURL = activeBookmarkId == bookmarkId ? activeURL : nil
lock.unlock()
if let currentURL {
return result(bookmarkId: bookmarkId, url: currentURL, name: try validateFolder(currentURL))
}
let data = try Data(contentsOf: bookmarkFile(for: bookmarkId, createDirectory: false))
var stale = false
let url = try URL(
resolvingBookmarkData: data,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &stale
)
guard url.startAccessingSecurityScopedResource() else { throw AccessError.denied }
var adopted = false
defer {
if !adopted { url.stopAccessingSecurityScopedResource() }
}
let name = try validateFolder(url)
if stale {
let refreshed = try url.bookmarkData(
options: .minimalBookmark,
includingResourceValuesForKeys: nil,
relativeTo: nil
)
try refreshed.write(to: bookmarkFile(for: bookmarkId, createDirectory: true), options: .atomic)
}
stageScope(url: url, bookmarkId: bookmarkId)
adopted = true
return result(bookmarkId: bookmarkId, url: url, name: name)
}
private func validateFolder(_ url: URL) throws -> String {
let values = try url.resourceValues(forKeys: [.isDirectoryKey, .nameKey])
guard values.isDirectory == true else { throw AccessError.notFolder }
guard try url.checkResourceIsReachable() else { throw AccessError.unavailable }
return values.name ?? url.lastPathComponent
}
private func stageScope(url: URL, bookmarkId: String) {
lock.lock()
let previous = stagedURL
stagedURL = url
stagedBookmarkId = bookmarkId
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func commitStagedScope() {
lock.lock()
guard let next = stagedURL else {
lock.unlock()
return
}
let previous = activeURL
activeURL = next
activeBookmarkId = stagedBookmarkId
stagedURL = nil
stagedBookmarkId = nil
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func rollbackStagedScope() {
lock.lock()
let staged = stagedURL
stagedURL = nil
stagedBookmarkId = nil
lock.unlock()
staged?.stopAccessingSecurityScopedResource()
}
private func releaseCurrentScope() {
lock.lock()
let previous = activeURL
activeURL = nil
activeBookmarkId = nil
lock.unlock()
previous?.stopAccessingSecurityScopedResource()
}
private func takePickerInvoke() -> Invoke? {
let invoke = pickerInvoke
pickerInvoke = nil
return invoke
}
private func bookmarkDirectory(create: Bool) throws -> URL {
let base = try fileManager.url(
for: .applicationSupportDirectory,
in: .userDomainMask,
appropriateFor: nil,
create: true
)
let directory = base.appendingPathComponent("VaultBookmarks", isDirectory: true)
if create {
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
}
return directory
}
private func bookmarkFile(for id: String, createDirectory: Bool) throws -> URL {
guard UUID(uuidString: id) != nil else { throw AccessError.missing }
let directory = try bookmarkDirectory(create: createDirectory)
return directory.appendingPathComponent("\(id).bookmark")
}
private func existingBookmarkId(for selectedURL: URL) -> String? {
guard
let directory = try? bookmarkDirectory(create: true),
let files = try? fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: nil
)
else { return nil }
let selectedPath = selectedURL.standardizedFileURL.path
for file in files where file.pathExtension == "bookmark" {
guard let data = try? Data(contentsOf: file) else { continue }
var stale = false
guard
let resolved = try? URL(
resolvingBookmarkData: data,
options: [],
relativeTo: nil,
bookmarkDataIsStale: &stale
),
resolved.standardizedFileURL.path == selectedPath
else { continue }
let id = file.deletingPathExtension().lastPathComponent
if UUID(uuidString: id) != nil { return id }
}
return nil
}
private func result(bookmarkId: String, url: URL, name: String) -> [String: Any] {
["bookmarkId": bookmarkId, "path": url.path, "name": name, "cancelled": false]
}
private func cancelledResult() -> [String: Any] {
["bookmarkId": "", "path": "", "name": "", "cancelled": true]
}
private func message(for error: Error) -> String {
if let accessError = error as? AccessError { return accessError.localizedDescription }
return "The selected folder is unavailable. Open Files and try again."
}
}
private enum AccessError: LocalizedError {
case denied
case missing
case notFolder
case unavailable
var errorDescription: String? {
switch self {
case .denied:
return "HelixNotes no longer has access to this folder. Choose it again in Files."
case .missing:
return "Saved access to this folder is missing. Choose it again in Files."
case .notFolder:
return "The selected item is not a folder."
case .unavailable:
return "The selected folder is unavailable. Open Files and try again."
}
}
}
@_cdecl("init_plugin_ios_vault_access")
func initPlugin() -> Plugin {
IosVaultAccessPlugin()
}
@@ -0,0 +1,92 @@
use serde::{Deserialize, Serialize};
use tauri::{
plugin::{Builder, PluginApi, PluginHandle, TauriPlugin},
AppHandle, Manager, Runtime,
};
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct FolderSelection {
pub bookmark_id: String,
pub path: String,
pub name: String,
#[serde(default)]
pub cancelled: bool,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct BookmarkRequest<'a> {
bookmark_id: &'a str,
}
#[cfg(target_os = "ios")]
tauri::ios_plugin_binding!(init_plugin_ios_vault_access);
pub struct IosVaultAccess<R: Runtime>(PluginHandle<R>);
impl<R: Runtime> IosVaultAccess<R> {
pub fn choose_folder(&self) -> Result<FolderSelection, String> {
self.0
.run_mobile_plugin("chooseFolder", ())
.map_err(|error| error.to_string())
}
pub fn resolve_folder(&self, bookmark_id: &str) -> Result<FolderSelection, String> {
self.0
.run_mobile_plugin("resolveFolder", BookmarkRequest { bookmark_id })
.map_err(|error| error.to_string())
}
pub fn release_active(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("releaseActive", ())
.map_err(|error| error.to_string())
}
pub fn commit_staged(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("commitStaged", ())
.map_err(|error| error.to_string())
}
pub fn rollback_staged(&self) -> Result<(), String> {
self.0
.run_mobile_plugin("rollbackStaged", ())
.map_err(|error| error.to_string())
}
pub fn forget_bookmark(&self, bookmark_id: &str) -> Result<(), String> {
self.0
.run_mobile_plugin("forgetBookmark", BookmarkRequest { bookmark_id })
.map_err(|error| error.to_string())
}
}
pub trait IosVaultAccessExt<R: Runtime> {
fn ios_vault_access(&self) -> &IosVaultAccess<R>;
}
impl<R: Runtime, T: Manager<R>> IosVaultAccessExt<R> for T {
fn ios_vault_access(&self) -> &IosVaultAccess<R> {
self.state::<IosVaultAccess<R>>().inner()
}
}
fn initialize<R: Runtime>(
_app: &AppHandle<R>,
api: PluginApi<R, ()>,
) -> tauri::Result<IosVaultAccess<R>> {
let handle = api.register_ios_plugin(init_plugin_ios_vault_access)?;
Ok(IosVaultAccess(handle))
}
pub fn init<R: Runtime>() -> TauriPlugin<R> {
Builder::new("ios-vault-access")
.setup(|app, api| {
let access = initialize(app, api)?;
app.manage(access);
Ok(())
})
.build()
}
+8 -6
View File
@@ -405,7 +405,7 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
let body = json!({ let body = json!({
"model": model, "model": model,
"max_tokens": 10, "max_tokens": 20,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
@@ -434,12 +434,14 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
} }
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> { async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new(); let client = Client::new();
let is_gpt5 = model.starts_with("gpt-5");
let token_key = if is_gpt5 { "max_completion_tokens" } else { "max_tokens" };
let body = json!({ let body = json!({
"model": model, "model": model,
"max_tokens": 10, token_key: 20,
"messages": [ "messages": [
{ {
"role": "user", "role": "user",
"content": "Hi" "content": "Hi"
+320 -507
View File
@@ -5,15 +5,79 @@ use crate::vault::{operations, watcher};
use std::path::Path; use std::path::Path;
use tauri::{AppHandle, Manager, State}; use tauri::{AppHandle, Manager, State};
fn index_note_bg(state: &State<'_, AppState>, path: &str) {
let search = state.search_index.lock().ok().and_then(|g| g.clone());
if let Some(search) = search {
let p = path.to_string();
std::thread::spawn(move || { let _ = search.index_note(&p); });
}
}
fn remove_note_bg(state: &State<'_, AppState>, path: &str) {
let search = state.search_index.lock().ok().and_then(|g| g.clone());
if let Some(search) = search {
let p = path.to_string();
std::thread::spawn(move || { let _ = search.remove_note(&p); });
}
}
fn clear_vault_runtime(state: &State<'_, AppState>) -> Result<(), String> {
*state.watcher.lock().map_err(|error| error.to_string())? = None;
*state
.search_index
.lock()
.map_err(|error| error.to_string())? = None;
Ok(())
}
// ── Vault Management ── // ── Vault Management ──
#[tauri::command] #[cfg(target_os = "ios")]
pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> { use tauri_plugin_ios_vault_access::{FolderSelection, IosVaultAccessExt};
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalVaultResult {
pub bookmark_id: String,
pub path: String,
pub name: String,
}
#[cfg(target_os = "ios")]
impl From<FolderSelection> for ExternalVaultResult {
fn from(selection: FolderSelection) -> Self {
Self {
bookmark_id: selection.bookmark_id,
path: selection.path,
name: selection.name,
}
}
}
fn open_vault_path(
app: AppHandle,
state: &State<'_, AppState>,
path: String,
external: Option<(String, String)>,
) -> Result<(), String> {
operations::ensure_vault_structure(&path)?; operations::ensure_vault_structure(&path)?;
// Initialize search index - rebuild in background on mobile (sandboxed FS is slow) // Stage the search index and watcher before replacing the active runtime.
let search = std::sync::Arc::new(SearchIndex::new(&path)?); let search = std::sync::Arc::new(SearchIndex::new(&path)?);
#[cfg(mobile)] #[cfg(target_os = "ios")]
{
if external.is_some() {
search.rebuild(&path)?;
} else {
let search_bg = search.clone();
let vault = path.clone();
std::thread::spawn(move || {
let _ = search_bg.rebuild(&vault);
log::info!("mobile: search index rebuild complete");
});
}
}
#[cfg(all(mobile, not(target_os = "ios")))]
{ {
let search_bg = search.clone(); let search_bg = search.clone();
let vault = path.clone(); let vault = path.clone();
@@ -23,43 +87,238 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
}); });
} }
#[cfg(desktop)] #[cfg(desktop)]
{ search.rebuild(&path)?;
search.rebuild(&path)?;
}
*state.search_index.lock().map_err(|e| e.to_string())? = Some(search);
// Start file watcher let new_watcher = watcher::start_watcher(app.clone(), path.clone())?;
let w = watcher::start_watcher(app.clone(), path.clone())?;
*state.watcher.lock().map_err(|e| e.to_string())? = Some(w);
// Update config // Update config. External vaults use the bookmark as their stable identity;
// the resolved path is refreshed whenever the vault opens.
let mut search_slot = state.search_index.lock().map_err(|e| e.to_string())?;
let mut watcher_slot = state.watcher.lock().map_err(|e| e.to_string())?;
let mut config = state.config.lock().map_err(|e| e.to_string())?; let mut config = state.config.lock().map_err(|e| e.to_string())?;
if !config.vaults.iter().any(|v| v.path == path) { let mut next = config.clone();
let active_bookmark_id = external
.as_ref()
.map(|(bookmark_id, _)| bookmark_id.clone());
if let Some((bookmark_id, name)) = external {
if let Some(vault) = next
.vaults
.iter_mut()
.find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id.as_str()))
{
vault.path.clone_from(&path);
vault.name = name;
} else {
next.vaults.push(VaultConfig {
path: path.clone(),
name,
bookmark_id: Some(bookmark_id),
..Default::default()
});
}
} else if !next
.vaults
.iter()
.any(|vault| vault.bookmark_id.is_none() && vault.path == path)
{
let name = std::path::Path::new(&path) let name = std::path::Path::new(&path)
.file_name() .file_name()
.unwrap_or_default() .unwrap_or_default()
.to_string_lossy() .to_string_lossy()
.to_string(); .to_string();
config.vaults.push(VaultConfig { next.vaults.push(VaultConfig {
path: path.clone(), path: path.clone(),
name, name,
..Default::default() ..Default::default()
}); });
} }
config.active_vault = Some(path); next.active_vault = Some(path);
save_app_config(&config)?; next.active_bookmark_id = active_bookmark_id;
save_app_config(&next)?;
*search_slot = Some(search);
*watcher_slot = Some(new_watcher);
*config = next;
Ok(()) Ok(())
} }
#[tauri::command] #[tauri::command]
pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> { pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?; open_vault_path(app.clone(), &state, path, None)?;
config.vaults.retain(|v| v.path != path); #[cfg(target_os = "ios")]
if config.active_vault.as_deref() == Some(path.as_str()) { app.ios_vault_access().release_active()?;
config.active_vault = None; Ok(())
}
#[tauri::command]
pub async fn choose_external_vault(
app: AppHandle,
state: State<'_, AppState>,
) -> Result<Option<ExternalVaultResult>, String> {
#[cfg(target_os = "ios")]
{
let picker_app = app.clone();
let selection = tauri::async_runtime::spawn_blocking(move || {
picker_app.ios_vault_access().choose_folder()
})
.await
.map_err(|error| error.to_string())??;
if selection.cancelled {
return Ok(None);
}
let result = ExternalVaultResult::from(selection);
let bookmark_was_registered = state
.config
.lock()
.map_err(|error| error.to_string())?
.vaults
.iter()
.any(|vault| vault.bookmark_id.as_deref() == Some(result.bookmark_id.as_str()));
if let Err(error) = open_vault_path(
app.clone(),
&state,
result.path.clone(),
Some((result.bookmark_id.clone(), result.name.clone())),
) {
if bookmark_was_registered {
let _ = app.ios_vault_access().rollback_staged();
} else {
let _ = app
.ios_vault_access()
.forget_bookmark(&result.bookmark_id);
}
return Err(error);
}
app.ios_vault_access().commit_staged()?;
Ok(Some(result))
} }
save_app_config(&config)?; #[cfg(not(target_os = "ios"))]
{
let _ = (app, state);
Err("Files folders are only available on iOS.".to_string())
}
}
#[tauri::command]
pub async fn restore_external_vault(
app: AppHandle,
state: State<'_, AppState>,
bookmark_id: String,
) -> Result<ExternalVaultResult, String> {
#[cfg(target_os = "ios")]
{
let resolver_app = app.clone();
let selection = tauri::async_runtime::spawn_blocking(move || {
resolver_app.ios_vault_access().resolve_folder(&bookmark_id)
})
.await
.map_err(|error| error.to_string())??;
let result = ExternalVaultResult::from(selection);
if let Err(error) = open_vault_path(
app.clone(),
&state,
result.path.clone(),
Some((result.bookmark_id.clone(), result.name.clone())),
) {
let _ = app.ios_vault_access().rollback_staged();
return Err(error);
}
app.ios_vault_access().commit_staged()?;
Ok(result)
}
#[cfg(not(target_os = "ios"))]
{
let _ = (app, state, bookmark_id);
Err("Files folders are only available on iOS.".to_string())
}
}
#[tauri::command]
pub async fn remove_vault(
app: AppHandle,
state: State<'_, AppState>,
path: String,
bookmark_id: Option<String>,
) -> Result<(), String> {
let target = {
let config = state.config.lock().map_err(|error| error.to_string())?;
let vault = if let Some(bookmark_id) = bookmark_id.as_deref() {
config
.vaults
.iter()
.find(|vault| vault.bookmark_id.as_deref() == Some(bookmark_id))
} else {
config
.vaults
.iter()
.find(|vault| vault.bookmark_id.is_none() && vault.path == path)
};
vault.map(|vault| {
let is_active = if let Some(bookmark_id) = vault.bookmark_id.as_deref() {
config.active_bookmark_id.as_deref() == Some(bookmark_id)
} else {
config.active_bookmark_id.is_none()
&& config.active_vault.as_deref() == Some(vault.path.as_str())
};
(vault.path.clone(), vault.bookmark_id.clone(), is_active)
})
};
let Some((target_path, target_bookmark, is_active)) = target else {
return Ok(());
};
let old = state
.config
.lock()
.map_err(|error| error.to_string())?
.clone();
let mut next = old.clone();
if let Some(id) = target_bookmark.as_deref() {
next.vaults
.retain(|vault| vault.bookmark_id.as_deref() != Some(id));
} else {
next.vaults
.retain(|vault| vault.bookmark_id.is_some() || vault.path != target_path);
}
if is_active {
next.active_vault = None;
next.active_bookmark_id = None;
}
save_app_config(&next)?;
#[cfg(target_os = "ios")]
if let Some(id) = target_bookmark.as_deref() {
let forget_app = app.clone();
let id_to_forget = id.to_string();
let forget_result = match tauri::async_runtime::spawn_blocking(move || {
forget_app
.ios_vault_access()
.forget_bookmark(&id_to_forget)
})
.await
{
Ok(result) => result,
Err(error) => Err(error.to_string()),
};
if let Err(error) = forget_result {
return match save_app_config(&old) {
Ok(()) => Err(error),
Err(rollback_error) => Err(format!(
"{error} Configuration rollback also failed: {rollback_error}"
)),
};
}
}
#[cfg(not(target_os = "ios"))]
let _ = (&app, &target_bookmark);
if is_active {
clear_vault_runtime(&state)?;
}
*state.config.lock().map_err(|error| error.to_string())? = next;
Ok(()) Ok(())
} }
@@ -338,12 +597,8 @@ pub fn save_note(
operations::save_note(&path, &meta, &body)?; operations::save_note(&path, &meta, &body)?;
// Re-index note so search picks up changes immediately // Re-index note so search picks up changes (background to avoid blocking on FUSE fsync)
if let Ok(search_guard) = state.search_index.lock() { index_note_bg(&state, &path);
if let Some(ref search) = *search_guard {
let _ = search.index_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -354,18 +609,14 @@ pub fn create_note(
notebook_relative: Option<String>, notebook_relative: Option<String>,
title: String, title: String,
) -> Result<NoteEntry, String> { ) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_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_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?; let entry = operations::create_note(vault_path, notebook_relative.as_deref(), &title)?;
// Index new note // Index new note (background to avoid blocking on FUSE fsync)
if let Ok(search_guard) = state.search_index.lock() { index_note_bg(&state, &entry.path);
if let Some(ref search) = *search_guard {
let _ = search.index_note(&entry.path);
}
}
Ok(entry) Ok(entry)
} }
#[tauri::command] #[tauri::command]
@@ -374,11 +625,7 @@ pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Re
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?; let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
if let Ok(search_guard) = state.search_index.lock() { index_note_bg(&state, &entry.path);
if let Some(ref search) = *search_guard {
let _ = search.index_note(&entry.path);
}
}
Ok(entry) Ok(entry)
} }
@@ -397,12 +644,8 @@ pub fn delete_note(state: State<'_, AppState>, path: String) -> Result<(), Strin
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?; let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
operations::delete_note(vault_path, &path)?; operations::delete_note(vault_path, &path)?;
// Remove from index // Remove from index (background to avoid blocking on FUSE fsync)
if let Ok(search_guard) = state.search_index.lock() { remove_note_bg(&state, &path);
if let Some(ref search) = *search_guard {
let _ = search.remove_note(&path);
}
}
Ok(()) Ok(())
} }
@@ -473,7 +716,6 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
if !path.is_file() { if !path.is_file() {
continue; continue;
} }
let path_str = path.to_string_lossy();
if path.extension().and_then(|e| e.to_str()) != Some("md") { if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue; continue;
} }
@@ -492,9 +734,16 @@ pub fn get_all_note_titles(state: State<'_, AppState>) -> Result<Vec<NoteTitleEn
.to_string() .to_string()
}; };
// Store vault-relative path with forward slashes for cross-platform portability.
// Absolute paths (e.g. C:\Users\...) break when the vault is synced to another device.
let rel = path
.strip_prefix(vault)
.map(|r| r.to_string_lossy().replace('\\', "/").to_string())
.unwrap_or_else(|_| path.to_string_lossy().to_string());
entries.push(NoteTitleEntry { entries.push(NoteTitleEntry {
title, title,
path: path_str.to_string(), path: rel,
}); });
} }
@@ -781,11 +1030,7 @@ pub fn set_task_done(
} }
operations::save_note(&note_path, &meta, &new_body)?; operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() { index_note_bg(&state, &note_path);
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(()) Ok(())
} }
@@ -842,11 +1087,7 @@ pub fn set_task_priority(
} }
operations::save_note(&note_path, &meta, &new_body)?; operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() { index_note_bg(&state, &note_path);
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(()) Ok(())
} }
@@ -902,11 +1143,7 @@ pub fn set_task_due(
} }
operations::save_note(&note_path, &meta, &new_body)?; operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() { index_note_bg(&state, &note_path);
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(()) Ok(())
} }
@@ -1288,6 +1525,9 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> {
"success": true, "success": true,
"files_converted": result.files_converted, "files_converted": result.files_converted,
"links_converted": result.links_converted, "links_converted": result.links_converted,
"frontmatter_normalized": result.frontmatter_normalized,
"syntax_converted": result.syntax_converted,
"attachments_moved": result.attachments_moved,
}), }),
); );
} }
@@ -1306,15 +1546,13 @@ pub fn import_obsidian(app: AppHandle) -> Result<(), String> {
} }
fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult, String> { fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult, String> {
// Pause file watcher during import
let state = app.state::<AppState>(); let state = app.state::<AppState>();
state state
.importing .importing
.store(true, std::sync::atomic::Ordering::Relaxed); .store(true, std::sync::atomic::Ordering::Relaxed);
let result = do_import_obsidian_inner(vault_path); let result = crate::vault::import::import(vault_path);
// Resume file watcher
state state
.importing .importing
.store(false, std::sync::atomic::Ordering::Relaxed); .store(false, std::sync::atomic::Ordering::Relaxed);
@@ -1322,413 +1560,6 @@ fn do_import_obsidian(app: AppHandle, vault_path: &str) -> Result<ImportResult,
result result
} }
fn do_import_obsidian_inner(vault_path: &str) -> Result<ImportResult, String> {
let vault = std::path::Path::new(vault_path);
// Build file index: filename -> relative path from vault root
let mut file_index: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if let Ok(rel) = path.strip_prefix(vault) {
file_index.insert(name.to_string(), rel.to_string_lossy().to_string());
}
}
}
let wiki_embed =
regex::Regex::new(r"!\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
let wiki_link =
regex::Regex::new(r"\[\[([^\]|]+?)(?:\|([^\]]*))?\]\]").map_err(|e| e.to_string())?;
let md_img = regex::Regex::new(r"!\[([^\]]*)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
let md_link = regex::Regex::new(r"\[([^\]]+)\]\(([^)]+)\)").map_err(|e| e.to_string())?;
let mut files_converted: u64 = 0;
let mut links_converted: u64 = 0;
// Walk all markdown files
let md_files: Vec<_> = walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
let p = e.path();
p.is_file()
&& p.extension().and_then(|x| x.to_str()) == Some("md")
&& !p.to_string_lossy().contains("/.helixnotes/")
&& !p.to_string_lossy().contains("/.trash/")
})
.collect();
for entry in &md_files {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
let mut changed = false;
// Convert embeds first: ![[file.ext]] or ![[file.ext|alt]]
let after_embeds = wiki_embed
.replace_all(&content, |caps: &regex::Captures| {
changed = true;
links_converted += 1;
let file_ref = &caps[1];
let alt = caps.get(2).map(|m| m.as_str()).unwrap_or("");
let resolved = resolve_wiki_ref(&file_index, file_ref);
// Convert vault-root-relative path to note-relative
let abs_target = vault.join(&resolved);
let rel = pathdiff(
abs_target.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(resolved);
let encoded = rel.replace(' ', "%20");
// Use image syntax only for images/PDFs; regular link for other files
let ext = std::path::Path::new(file_ref)
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let is_embeddable = matches!(
ext.as_str(),
"png" | "jpg" | "jpeg" | "gif" | "svg" | "webp" | "bmp" | "ico" | "pdf"
);
if is_embeddable {
format!("![{}]({})", alt, encoded)
} else {
let display = if alt.is_empty() {
file_ref.rsplit('/').next().unwrap_or(file_ref)
} else {
alt
};
format!("[{}]({})", display, encoded)
}
})
.to_string();
// Convert wiki links: [[note]] or [[note|display]]
let after_links = wiki_link
.replace_all(&after_embeds, |caps: &regex::Captures| {
changed = true;
links_converted += 1;
let note_ref = &caps[1];
let display = caps.get(2).map(|m| m.as_str()).unwrap_or(note_ref);
let resolved = resolve_wiki_ref(&file_index, note_ref);
// Convert vault-root-relative path to note-relative
let abs_target = vault.join(&resolved);
let rel = pathdiff(
abs_target.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(resolved);
let encoded = rel.replace(' ', "%20");
format!("[{}]({})", display, encoded)
})
.to_string();
// Fix bare-filename standard markdown references: ![alt](filename.ext) where filename has no path separator
let after_img_fix = md_img
.replace_all(&after_links, |caps: &regex::Captures| {
let alt = &caps[1];
let src = &caps[2];
let decoded = percent_decode(src);
// Skip URLs and absolute paths
if decoded.starts_with("http") || decoded.starts_with('/') {
return format!("![{}]({})", alt, src);
}
// Try to resolve bare filename via file index
if !decoded.contains('/') {
if let Some(rel) = file_index.get(&decoded) {
changed = true;
links_converted += 1;
let abs_target = vault.join(rel);
let note_rel = pathdiff(
abs_target.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(rel.clone());
let encoded = note_rel.replace(' ', "%20");
return format!("![{}]({})", alt, encoded);
}
}
// Fix vault-root-relative paths: if the path resolves from vault root, make it note-relative
if decoded.contains('/') {
let vault_abs = vault.join(&decoded);
if vault_abs.exists() {
let note_rel = pathdiff(
vault_abs.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(decoded.clone());
if note_rel != decoded {
changed = true;
links_converted += 1;
let encoded = note_rel.replace(' ', "%20");
return format!("![{}]({})", alt, encoded);
}
}
}
format!("![{}]({})", alt, src)
})
.to_string();
let after_link_fix = md_link
.replace_all(&after_img_fix, |caps: &regex::Captures| {
let display = &caps[1];
let href = &caps[2];
let decoded = percent_decode(href);
// Skip URLs and absolute paths
if decoded.starts_with("http") || decoded.starts_with('/') {
return format!("[{}]({})", display, href);
}
// Try to resolve bare filename via file index
if !decoded.contains('/') {
if let Some(rel) = file_index.get(&decoded) {
changed = true;
links_converted += 1;
let abs_target = vault.join(rel);
let note_rel = pathdiff(
abs_target.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(rel.clone());
let encoded = note_rel.replace(' ', "%20");
return format!("[{}]({})", display, encoded);
}
}
// Fix vault-root-relative paths
if decoded.contains('/') {
let vault_abs = vault.join(&decoded);
if vault_abs.exists() {
let note_rel = pathdiff(
vault_abs.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(decoded.clone());
if note_rel != decoded {
changed = true;
links_converted += 1;
let encoded = note_rel.replace(' ', "%20");
return format!("[{}]({})", display, encoded);
}
}
}
format!("[{}]({})", display, href)
})
.to_string();
if changed {
let _ = std::fs::write(path, after_link_fix);
files_converted += 1;
}
}
// ── Phase 2: Move all non-.md files into .helixnotes/attachments/ preserving structure ──
let attachments_dir = vault.join(".helixnotes").join("attachments");
let _ = std::fs::create_dir_all(&attachments_dir);
let attachment_files: Vec<_> = walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
let p = e.path();
if !p.is_file() {
return false;
}
if p.extension().and_then(|x| x.to_str()) == Some("md") {
return false;
}
let path_str = p.to_string_lossy();
!path_str.contains("/.helixnotes/")
&& !path_str.contains("/.obsidian/")
&& !path_str.contains("/.trash/")
&& !path_str.contains("/.stfolder")
&& !path_str.contains("/.claude/")
})
.collect();
// Map: old vault-relative path -> new vault-relative path (preserving structure)
let mut moved_files: std::collections::HashMap<String, String> =
std::collections::HashMap::new();
for entry in &attachment_files {
let src_path = entry.path();
let old_rel = match src_path.strip_prefix(vault) {
Ok(r) => r.to_string_lossy().to_string(),
Err(_) => continue,
};
// Preserve directory structure: ArkHost/Files/img.png -> .helixnotes/attachments/ArkHost/Files/img.png
let new_rel = format!(".helixnotes/attachments/{}", old_rel);
let dest_path = vault.join(&new_rel);
if let Some(parent) = dest_path.parent() {
let _ = std::fs::create_dir_all(parent);
}
if std::fs::rename(src_path, &dest_path).is_ok()
|| std::fs::copy(src_path, &dest_path)
.and_then(|_| std::fs::remove_file(src_path))
.is_ok()
{
moved_files.insert(old_rel, new_rel);
}
}
// ── Phase 3: Rewrite references in markdown files to point to new locations ──
// Use regex to only replace inside ![...](...) and [...](...) patterns
if !moved_files.is_empty() {
let md_ref = regex::Regex::new(r"(!?\[[^\]]*\])\(([^)]+)\)").map_err(|e| e.to_string())?;
let md_files_pass2: Vec<_> = walkdir::WalkDir::new(vault)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| {
let p = e.path();
p.is_file()
&& p.extension().and_then(|x| x.to_str()) == Some("md")
&& !p.to_string_lossy().contains("/.helixnotes/")
&& !p.to_string_lossy().contains("/.trash/")
})
.collect();
for entry in &md_files_pass2 {
let path = entry.path();
let content = match std::fs::read_to_string(path) {
Ok(c) => c,
Err(_) => continue,
};
let note_dir = path.parent().unwrap_or(std::path::Path::new(""));
let mut changed_p3 = false;
let new_content = md_ref
.replace_all(&content, |caps: &regex::Captures| {
let prefix = &caps[1]; // ![alt] or [text]
let href = &caps[2];
let decoded_href = percent_decode(href);
// Skip URLs
if decoded_href.starts_with("http") || decoded_href.starts_with("data:") {
return format!("{}({})", prefix, href);
}
// Resolve the href to a vault-relative path (files already moved, can't use .exists())
// Try note-dir-relative first, then vault-root-relative
let abs_from_note = note_dir.join(&decoded_href);
let rel_from_note = abs_from_note
.strip_prefix(vault)
.ok()
.map(|r| r.to_string_lossy().to_string());
let rel_from_vault = decoded_href.clone();
// Check note-relative resolution first, then vault-root
let old_vault_rel = rel_from_note
.as_ref()
.filter(|r| moved_files.contains_key(r.as_str()))
.cloned()
.or_else(|| {
if moved_files.contains_key(&rel_from_vault) {
Some(rel_from_vault)
} else {
None
}
});
if let Some(old_rel) = old_vault_rel {
if let Some(new_rel) = moved_files.get(&old_rel) {
changed_p3 = true;
let new_abs = vault.join(new_rel);
let new_note_rel = pathdiff(
new_abs.to_str().unwrap_or(""),
note_dir.to_str().unwrap_or(""),
)
.unwrap_or(new_rel.clone());
let encoded = new_note_rel.replace(' ', "%20");
return format!("{}({})", prefix, encoded);
}
}
format!("{}({})", prefix, href)
})
.to_string();
if changed_p3 {
let _ = std::fs::write(path, new_content);
}
}
}
// Clean up empty directories left behind after moving files
cleanup_empty_dirs(vault);
Ok(ImportResult {
files_converted,
links_converted,
})
}
/// Remove empty directories recursively (bottom-up), skipping .helixnotes and .obsidian
fn cleanup_empty_dirs(root: &std::path::Path) {
let mut dirs: Vec<_> = walkdir::WalkDir::new(root)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.path().is_dir())
.map(|e| e.path().to_path_buf())
.collect();
// Sort by depth (deepest first) so we clean bottom-up
dirs.sort_by(|a, b| b.components().count().cmp(&a.components().count()));
for dir in dirs {
let dir_str = dir.to_string_lossy();
if dir_str.contains("/.helixnotes") || dir_str.contains("/.obsidian") || dir == root {
continue;
}
if let Ok(mut entries) = std::fs::read_dir(&dir) {
if entries.next().is_none() {
let _ = std::fs::remove_dir(&dir);
}
}
}
}
/// Compute a relative path from `base` to `target`
fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
let target = std::path::Path::new(target);
let base = std::path::Path::new(base);
let target_components: Vec<_> = target.components().collect();
let base_components: Vec<_> = base.components().collect();
// Find common prefix length
let common = target_components
.iter()
.zip(base_components.iter())
.take_while(|(a, b)| a == b)
.count();
let mut result = std::path::PathBuf::new();
// Go up from base to common ancestor
for _ in common..base_components.len() {
result.push("..");
}
// Go down from common ancestor to target
for component in &target_components[common..] {
result.push(component);
}
Ok(result.to_string_lossy().to_string())
}
// ── Orphaned attachment cleanup ── // ── Orphaned attachment cleanup ──
#[derive(serde::Serialize)] #[derive(serde::Serialize)]
@@ -1865,37 +1696,6 @@ fn percent_decode(s: &str) -> String {
result result
} }
fn resolve_wiki_ref(
file_index: &std::collections::HashMap<String, String>,
reference: &str,
) -> String {
let reference = reference.trim();
// Strip Obsidian heading anchors (#) and block references (^) before lookup
let reference = reference.split('#').next().unwrap_or(reference).trim();
let reference = reference.split('^').next().unwrap_or(reference).trim();
// Direct match by filename
if let Some(rel) = file_index.get(reference) {
return rel.clone();
}
// Try with .md extension
let with_md = format!("{}.md", reference);
if let Some(rel) = file_index.get(&with_md) {
return rel.clone();
}
// Try matching just the filename part (e.g. "folder/note" -> "note.md")
if let Some(name) = reference.rsplit('/').next() {
if let Some(rel) = file_index.get(name) {
return rel.clone();
}
let name_md = format!("{}.md", name);
if let Some(rel) = file_index.get(&name_md) {
return rel.clone();
}
}
// Fallback: return as-is
reference.to_string()
}
// ── Open file/URL with system default handler ── // ── Open file/URL with system default handler ──
fn xdg_open(arg: &str) -> Result<(), String> { fn xdg_open(arg: &str) -> Result<(), String> {
@@ -1935,6 +1735,10 @@ fn xdg_open(arg: &str) -> Result<(), String> {
.spawn() .spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?; .map_err(|e| format!("Failed to open {}: {}", arg, e))?;
} }
#[cfg(mobile)]
{
let _ = arg;
}
Ok(()) Ok(())
} }
@@ -2424,6 +2228,7 @@ pub fn ai_ask(
// path resolver, since dirs::config_dir() is not reliable in the app sandbox. // 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(); static MOBILE_CONFIG_DIR: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
#[cfg(mobile)]
pub fn set_mobile_config_dir(path: std::path::PathBuf) { pub fn set_mobile_config_dir(path: std::path::PathBuf) {
let _ = MOBILE_CONFIG_DIR.set(path); let _ = MOBILE_CONFIG_DIR.set(path);
} }
@@ -2509,6 +2314,14 @@ pub fn get_install_type() -> String {
return forced.to_string(); return forced.to_string();
} }
} }
// On mobile (iOS/Android) the concept of install type is irrelevant and the
// Linux detection below would attempt std::process::Command which is forbidden
// in the iOS sandbox. Return early.
if cfg!(mobile) {
return "mobile".to_string();
}
if cfg!(target_os = "macos") { if cfg!(target_os = "macos") {
"macos".to_string() "macos".to_string()
} else if cfg!(target_os = "windows") { } else if cfg!(target_os = "windows") {
+55
View File
@@ -57,6 +57,9 @@ pub fn run() {
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.setup(move |app| { .setup(move |app| {
#[cfg(target_os = "ios")]
app.handle().plugin(tauri_plugin_ios_vault_access::init())?;
if cfg!(debug_assertions) { if cfg!(debug_assertions) {
app.handle().plugin( app.handle().plugin(
tauri_plugin_log::Builder::default() tauri_plugin_log::Builder::default()
@@ -85,6 +88,13 @@ pub fn run() {
setup_tray(app)?; setup_tray(app)?;
} }
#[cfg(target_os = "macos")]
{
if let Some(window) = app.get_webview_window("main") {
fix_macos_traffic_lights(&window);
}
}
// Check CLI args for a .md file path on initial launch // Check CLI args for a .md file path on initial launch
#[cfg(desktop)] #[cfg(desktop)]
{ {
@@ -112,6 +122,8 @@ pub fn run() {
.manage(app_state) .manage(app_state)
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
commands::open_vault, commands::open_vault,
commands::choose_external_vault,
commands::restore_external_vault,
commands::remove_vault, commands::remove_vault,
commands::get_app_config, commands::get_app_config,
commands::set_theme, commands::set_theme,
@@ -387,3 +399,46 @@ fn percent_decode(input: &str) -> String {
} }
String::from_utf8_lossy(&output).to_string() String::from_utf8_lossy(&output).to_string()
} }
#[cfg(target_os = "macos")]
fn fix_macos_traffic_lights(window: &tauri::WebviewWindow) {
use objc2_app_kit::{NSView, NSWindow, NSWindowButton};
let ns_window_ptr = match window.ns_window() {
Ok(ptr) => ptr,
Err(_) => return,
};
let ns_window: &NSWindow = unsafe { &*(ns_window_ptr as *const NSWindow) };
let close = match ns_window.standardWindowButton(NSWindowButton::CloseButton) {
Some(btn) => btn,
None => return,
};
let miniaturize = match ns_window.standardWindowButton(NSWindowButton::MiniaturizeButton) {
Some(btn) => btn,
None => return,
};
let zoom = match ns_window.standardWindowButton(NSWindowButton::ZoomButton) {
Some(btn) => btn,
None => return,
};
let superview = match close.superview() {
Some(sv) => sv,
None => return,
};
let sv_height = NSView::frame(&superview).size.height;
let btn_height = NSView::frame(&close).size.height;
if sv_height <= 0.0 || btn_height <= 0.0 {
return;
}
let centered_y = (sv_height - btn_height) / 2.0;
for btn in [&close, &miniaturize, &zoom] {
let mut frame = NSView::frame(btn);
frame.origin.y = centered_y;
btn.setFrameOrigin(frame.origin);
}
}
+3 -2
View File
@@ -312,8 +312,9 @@ impl WebdavClient {
.send() .send()
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let s = resp.status().as_u16(); let s = resp.status().as_u16();
// 201 created; 405 already exists; 301 redirect-to-existing. // 201 created; 405 already exists; 301 redirect-to-existing;
if resp.status().is_success() || s == 405 || s == 301 { // 403 some servers (openmediavault et al.) return 403 instead of 405.
if resp.status().is_success() || s == 405 || s == 301 || s == 403 {
Ok(()) Ok(())
} else { } else {
Err(format!("MKCOL {} failed: HTTP {}", relpath, s)) Err(format!("MKCOL {} failed: HTTP {}", relpath, s))
+8
View File
@@ -54,6 +54,8 @@ pub struct NoteContent {
pub struct VaultConfig { pub struct VaultConfig {
pub path: String, pub path: String,
pub name: String, pub name: String,
#[serde(default)]
pub bookmark_id: Option<String>,
// Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration). // Per-vault WebDAV sync (was previously global on AppConfig; those fields are kept deprecated for migration).
#[serde(default)] #[serde(default)]
pub sync_provider: Option<String>, pub sync_provider: Option<String>,
@@ -98,6 +100,8 @@ pub struct CustomTheme {
pub struct AppConfig { pub struct AppConfig {
pub vaults: Vec<VaultConfig>, pub vaults: Vec<VaultConfig>,
pub active_vault: Option<String>, pub active_vault: Option<String>,
#[serde(default)]
pub active_bookmark_id: Option<String>,
pub theme: String, pub theme: String,
#[serde(default)] #[serde(default)]
pub accent_color: Option<String>, pub accent_color: Option<String>,
@@ -255,6 +259,7 @@ impl Default for AppConfig {
Self { Self {
vaults: Vec::new(), vaults: Vec::new(),
active_vault: None, active_vault: None,
active_bookmark_id: None,
theme: "system".to_string(), theme: "system".to_string(),
accent_color: None, accent_color: None,
font_size: None, font_size: None,
@@ -393,6 +398,9 @@ pub struct FileEvent {
pub struct ImportResult { pub struct ImportResult {
pub files_converted: u64, pub files_converted: u64,
pub links_converted: u64, pub links_converted: u64,
pub frontmatter_normalized: u64,
pub syntax_converted: u64,
pub attachments_moved: u64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
+2 -2
View File
@@ -15,7 +15,7 @@ struct RawFrontmatter {
} }
/// Try to parse a date string in multiple common formats. /// Try to parse a date string in multiple common formats.
fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> { pub fn parse_date_flexible(s: &str) -> Option<chrono::DateTime<Utc>> {
let s = s.trim(); let s = s.trim();
// RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00) // RFC 3339 / ISO 8601 with timezone (e.g. 2024-01-15T10:30:00+00:00)
if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() { if let Ok(dt) = s.parse::<chrono::DateTime<Utc>>() {
@@ -191,7 +191,7 @@ pub fn merge_frontmatter(original_raw: &str, meta: &NoteMeta, body: &str) -> Str
format!("---\n{}---\n{}", yaml_str, body_with_heading) format!("---\n{}---\n{}", yaml_str, body_with_heading)
} }
fn filename_to_title(filename: &str) -> String { pub fn filename_to_title(filename: &str) -> String {
let stem = filename.trim_end_matches(".md"); let stem = filename.trim_end_matches(".md");
// Only replace dashes/underscores acting as word separators (between non-space chars). // Only replace dashes/underscores acting as word separators (between non-space chars).
// Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle"). // Keep dashes that are surrounded by spaces (e.g. "Title - Subtitle").
File diff suppressed because it is too large Load Diff
+1
View File
@@ -1,3 +1,4 @@
pub mod frontmatter; pub mod frontmatter;
pub mod import;
pub mod operations; pub mod operations;
pub mod watcher; pub mod watcher;
+9
View File
@@ -61,6 +61,15 @@ pub fn start_watcher(app: AppHandle, vault_path: String) -> Result<RecommendedWa
}; };
let _ = app.emit("file-changed", &fe); let _ = app.emit("file-changed", &fe);
} }
// On mobile, throttle event emission to prevent IPC flooding
// on FUSE filesystems where Syncthing/other apps generate
// constant file activity that blocks the Tauri command channel.
#[cfg(mobile)]
{
std::thread::sleep(Duration::from_secs(2));
while rx.try_recv().is_ok() {}
}
} }
Err(e) => { Err(e) => {
log::error!("File watcher error: {}", e); log::error!("File watcher error: {}", e);
+3
View File
@@ -55,6 +55,9 @@
"appimage": { "appimage": {
"bundleMediaFramework": false "bundleMediaFramework": false
} }
},
"macOS": {
"entitlements": "entitlements.plist"
} }
} }
} }
+1 -1
View File
@@ -9,7 +9,7 @@
"hiddenTitle": true, "hiddenTitle": true,
"trafficLightPosition": { "trafficLightPosition": {
"x": 12, "x": 12,
"y": 10 "y": 12
} }
} }
] ]
-4
View File
@@ -1142,10 +1142,6 @@ body.resizing {
/* ── Mobile (Android/iOS) ── */ /* ── Mobile (Android/iOS) ── */
@media (max-width: 768px) { @media (max-width: 768px) {
body { body {
padding-top: env(safe-area-inset-top);
padding-bottom: env(safe-area-inset-bottom);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
touch-action: manipulation; touch-action: manipulation;
} }
+11 -2
View File
@@ -15,14 +15,23 @@ import type {
BackupEntry, BackupEntry,
VersionEntry, VersionEntry,
TaskItem, TaskItem,
ExternalVaultResult,
} from "./types"; } from "./types";
export async function openVault(path: string): Promise<void> { export async function openVault(path: string): Promise<void> {
return invoke("open_vault", { path }); return invoke("open_vault", { path });
} }
export async function removeVault(path: string): Promise<void> { export async function chooseExternalVault(): Promise<ExternalVaultResult | null> {
return invoke("remove_vault", { path }); return invoke("choose_external_vault");
}
export async function restoreExternalVault(bookmarkId: string): Promise<ExternalVaultResult> {
return invoke("restore_external_vault", { bookmarkId });
}
export async function removeVault(path: string, bookmarkId?: string | null): Promise<void> {
return invoke("remove_vault", { path, bookmarkId: bookmarkId ?? null });
} }
export async function getAppConfig(): Promise<AppConfig> { export async function getAppConfig(): Promise<AppConfig> {
+33 -7
View File
@@ -61,7 +61,7 @@
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = $derived($platformIsMobile); const isMobile = $derived($platformIsMobile);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import { darkThemes, isAndroid } from '$lib/platform'; import { darkThemes, isAndroid } from '$lib/platform';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
@@ -252,11 +252,11 @@
// Tasks view: the editor pane shows a placeholder until a task is opened from the list. // Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false); let taskNoteOpened = $state(false);
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string, task?: TaskItem) {
// Selecting a real vault note exits viewer mode // Selecting a real vault note exits viewer mode
$viewerNote = null; $viewerNote = null;
taskNoteOpened = true; taskNoteOpened = true;
editor?.loadNote(path, content); editor?.loadNote(path, content, task);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
@@ -614,6 +614,26 @@
// Run sidebar and note list refresh in parallel // Run sidebar and note list refresh in parallel
await Promise.all([sidebar?.refresh(), noteList?.refresh()]); await Promise.all([sidebar?.refresh(), noteList?.refresh()]);
// Auto-cleanup orphaned attachments in the background
setTimeout(async () => {
if (get(editorDirty)) return; // skip if user is actively editing
try {
const orphans = await findOrphanedAttachments();
if (orphans.length > 0) {
if (get(editorDirty)) return; // re-check after async scan
const moved = await trashOrphanedAttachments(orphans.map((o) => o.name));
if (moved > 0) {
const toast = document.createElement('div');
toast.style.cssText = 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);background:var(--bg-secondary);color:var(--text-primary);padding:10px 18px;border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,.15);border:1px solid var(--border-color);font-size:13px;z-index:10000;opacity:0;transition:opacity .3s;pointer-events:none';
toast.textContent = `Moved ${moved} orphaned attachment${moved > 1 ? 's' : ''} to trash`;
document.body.appendChild(toast);
requestAnimationFrame(() => (toast.style.opacity = '1'));
setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 4000);
}
}
} catch (_) {}
}, 3000);
// Restore the last session (view + open note) if enabled. // Restore the last session (view + open note) if enabled.
if ($appConfig?.restore_last_session) { if ($appConfig?.restore_last_session) {
const vault = $appConfig?.active_vault; const vault = $appConfig?.active_vault;
@@ -679,7 +699,7 @@
} }
} }
tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0]))); tags.set(Array.from(tagMap.entries()).sort((a, b) => a[0].localeCompare(b[0])));
}, 3000); }, 10000);
unlistenFileChange = await listen<FileEvent>('file-changed', () => { unlistenFileChange = await listen<FileEvent>('file-changed', () => {
debouncedRefresh(); debouncedRefresh();
}); });
@@ -895,7 +915,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'editor'}> <div class="mobile-panel" class:active={$mobileView === 'editor'}>
<Editor bind:this={editor} /> <Editor bind:this={editor} />
@@ -956,7 +976,7 @@
{#if !$notelistCollapsed} {#if !$notelistCollapsed}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
@@ -1137,7 +1157,13 @@
.mobile-shell { .mobile-shell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh; height: 100dvh;
box-sizing: border-box;
padding:
env(safe-area-inset-top, 0px)
env(safe-area-inset-right, 0px)
env(safe-area-inset-bottom, 0px)
env(safe-area-inset-left, 0px);
overflow: hidden; overflow: hidden;
background: var(--bg-primary); background: var(--bg-primary);
} }
File diff suppressed because it is too large Load Diff
+90 -24
View File
@@ -46,8 +46,8 @@
import TagSuggestInput from './TagSuggestInput.svelte'; import TagSuggestInput from './TagSuggestInput.svelte';
import { isMobile, isAndroid } from '$lib/platform'; import { isMobile, isAndroid } from '$lib/platform';
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) => {} }: { let { onNoteSelected = (_path: string, _content: string, _task?: TaskItem) => {}, 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; onNoteSelected?: (path: string, content: string, task?: TaskItem) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void; onNoteCreated?: () => void;
@@ -409,13 +409,14 @@
$activeNote = content; $activeNote = content;
$activeNotePath = task.note_path; $activeNotePath = task.note_path;
$editorDirty = false; $editorDirty = false;
onNoteSelected(task.note_path, content.content); onNoteSelected(task.note_path, content.content, task);
} catch (e) { } catch (e) {
console.error('Failed to open task note:', e); console.error('Failed to open task note:', e);
} }
} }
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'quickaccess' || $viewMode === 'trash') return;
if ($viewMode === 'daily') { if ($viewMode === 'daily') {
const today = new Date(); const today = new Date();
const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`; const date = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
@@ -441,8 +442,12 @@
const entry = await createNote(nbRelative, 'Untitled'); const entry = await createNote(nbRelative, 'Untitled');
if ($sortMode === 'custom') appendManualNoteOrder(entry.path); if ($sortMode === 'custom') appendManualNoteOrder(entry.path);
noteCache.clear(); noteCache.clear();
await refresh(); $notes = [entry, ...$notes];
await selectNote(entry); onBeforeNoteSwitch();
$activeNote = { path: entry.path, meta: entry.meta, content: '\n', raw: '' };
$activeNotePath = entry.path;
$editorDirty = false;
onNoteSelected(entry.path, '\n');
onNoteCreated(); onNoteCreated();
} catch (e) { } catch (e) {
console.error('Failed to create note:', e); console.error('Failed to create note:', e);
@@ -453,7 +458,7 @@
const target = e.target as HTMLElement | null; const target = e.target as HTMLElement | null;
if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return; if (target?.closest('button, input, .note-item, .context-menu, .sort-menu, .batch-move-picker, .cal')) return;
e.stopPropagation(); e.stopPropagation();
if ($viewMode === 'tasks' || $viewMode === 'trash') return; if ($viewMode === 'tasks' || $viewMode === 'trash' || $viewMode === 'quickaccess') return;
handleCreateNote(); handleCreateNote();
} }
@@ -782,6 +787,19 @@
} }
} }
function isFiled(note: NoteEntry): boolean {
const vaultRoot = $appConfig?.active_vault?.replace(/\\/g, '/').replace(/\/$/, '');
if (!vaultRoot) return false;
const normalizedPath = note.path.replace(/\\/g, '/');
const noteDir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/'));
return noteDir !== vaultRoot;
}
function unfileNote(note: NoteEntry) {
const vaultRoot = $appConfig?.active_vault;
if (vaultRoot) handleMoveNote(note, vaultRoot);
}
function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } { function clampMenu(x: number, y: number, menuWidth = 220, menuHeight = 400): { x: number; y: number } {
if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8; if (x + menuWidth > window.innerWidth) x = window.innerWidth - menuWidth - 8;
if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8; if (y + menuHeight > window.innerHeight) y = window.innerHeight - menuHeight - 8;
@@ -989,7 +1007,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
{#if $viewMode !== 'trash'} {#if $viewMode !== 'trash' && $viewMode !== 'quickaccess'}
<button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}> <button class={isMobile ? 'mobile-create-btn' : 'icon-btn'} onclick={handleCreateNote} title={`New note (${modKey}+N)`}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '2'} stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width={isMobile ? '3' : '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" /> <line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
@@ -1005,8 +1023,14 @@
<span class="selection-count">{selectedPaths.size} selected</span> <span class="selection-count">{selectedPaths.size} selected</span>
<div class="selection-actions"> <div class="selection-actions">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<button class="selection-action" onclick={handleBatchRestore}>Restore</button> <button class="selection-action" onclick={handleBatchRestore} title="Restore selected">
<button class="selection-action danger" onclick={handleBatchPermanentDelete}>Delete</button> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
Restore
</button>
<button class="selection-action danger" onclick={handleBatchPermanentDelete} title="Delete permanently">
<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="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>
Delete
</button>
{:else} {:else}
<button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button> <button class="selection-action" onclick={() => { batchMovePicker = true; }}>Move</button>
<button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button> <button class="selection-action" onclick={() => openBatchTagEdit()}>Tag</button>
@@ -1130,6 +1154,8 @@
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
{:else if $viewMode === 'quickaccess'}
<p>No starred notes</p>
{:else} {:else}
<p>No notes yet</p> <p>No notes yet</p>
<button class="btn-link" onclick={handleCreateNote}>Create your first note</button> <button class="btn-link" onclick={handleCreateNote}>Create your first note</button>
@@ -1319,8 +1345,18 @@
</span> </span>
{/if} {/if}
</div> </div>
{/if} {/if}
</button> {#if $viewMode === 'trash'}
<span class="trash-row-actions">
<span class="trash-row-btn" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handleRestore(note); }} title="Restore" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handleRestore(note); } }}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/></svg>
</span>
<span class="trash-row-btn danger" role="button" tabindex="0" onclick={(e) => { e.stopPropagation(); handlePermanentDelete(note); }} title="Delete permanently" onkeydown={(e) => { if (e.key === 'Enter') { e.stopPropagation(); handlePermanentDelete(note); } }}>
<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="3 6 5 6 21 6"/><path d="M19 6l-2 14a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6"/><path d="M14 11v6"/><path d="M9 6V4h6v2"/></svg>
</span>
</span>
{/if}
</button>
{/if} {/if}
{/snippet} {/snippet}
@@ -1512,6 +1548,14 @@
</svg> </svg>
Move to... Move to...
</button> </button>
{#if isFiled(contextMenu.note)}
<button onclick={() => unfileNote(contextMenu!.note)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z"/><path d="M9 13h6"/>
</svg>
Unfile Note
</button>
{/if}
{#if !isMobile} {#if !isMobile}
<button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}> <button onclick={async () => { const n = contextMenu!.note; contextMenu = null; await selectNote(n); setTimeout(() => window.print(), 300); }}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -1634,19 +1678,6 @@
padding: 4px; padding: 4px;
} }
.list-content::-webkit-scrollbar {
width: 8px;
}
.list-content::-webkit-scrollbar-thumb {
background: var(--text-tertiary);
border-radius: 4px;
}
.list-content::-webkit-scrollbar-thumb:hover {
background: var(--text-secondary);
}
.note-list-section { .note-list-section {
padding: 14px 12px 4px; padding: 14px 12px 4px;
font-size: 0.7rem; font-size: 0.7rem;
@@ -1673,6 +1704,7 @@
text-align: left; text-align: left;
margin-bottom: 1px; margin-bottom: 1px;
contain: content; contain: content;
position: relative;
} }
.note-item:hover { .note-item:hover {
@@ -1719,6 +1751,9 @@
} }
.selection-action { .selection-action {
display: inline-flex;
align-items: center;
gap: 4px;
background: none; background: none;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
color: var(--text-primary); color: var(--text-primary);
@@ -1733,6 +1768,37 @@
background: var(--bg-hover); background: var(--bg-hover);
} }
.trash-row-actions {
position: absolute;
right: 6px;
top: 50%;
transform: translateY(-50%);
display: none;
gap: 2px;
}
.note-item:hover .trash-row-actions {
display: flex;
}
.trash-row-btn {
cursor: pointer;
padding: 4px;
border-radius: 4px;
color: var(--text-secondary);
display: flex;
align-items: center;
}
.trash-row-btn:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.trash-row-btn.danger:hover {
color: var(--danger);
background: color-mix(in srgb, var(--danger) 10%, transparent);
}
.note-list.mobile .trash-row-actions {
display: flex;
}
.selection-action.danger { .selection-action.danger {
color: var(--danger); color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color)); border-color: color-mix(in srgb, var(--danger) 30%, var(--border-color));
+26 -2
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView } from '$lib/stores/app'; import { showSearch, activeNote, activeNotePath, editorDirty, appConfig, mobileView, notebooks, activeNotebook, activeTag, viewMode } from '$lib/stores/app';
import { searchNotes, readNote } from '$lib/api'; import { searchNotes, readNote } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import type { SearchResult } from '$lib/types'; import type { SearchResult, NotebookEntry } from '$lib/types';
import { isMobile } from '$lib/platform'; import { isMobile } from '$lib/platform';
let query = $state(''); let query = $state('');
@@ -84,6 +84,17 @@
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;'); return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
} }
function findNotebookByPath(nbs: NotebookEntry[], target: string): NotebookEntry | null {
for (const nb of nbs) {
if (nb.path === target) return nb;
if (nb.children.length) {
const found = findNotebookByPath(nb.children, target);
if (found) return found;
}
}
return null;
}
async function openResult(result: SearchResult) { async function openResult(result: SearchResult) {
try { try {
const content = await readNote(result.path); const content = await readNote(result.path);
@@ -91,6 +102,19 @@
$activeNotePath = result.path; $activeNotePath = result.path;
$editorDirty = false; $editorDirty = false;
$showSearch = false; $showSearch = false;
// Reveal the note in the notes list: switch to its notebook (or All Notes).
const sep = result.path.includes('\\') ? '\\' : '/';
const parentDir = result.path.slice(0, result.path.lastIndexOf(sep));
const nb = parentDir ? findNotebookByPath($notebooks, parentDir) : null;
if (nb) {
$activeNotebook = nb;
$activeTag = null;
$viewMode = 'notebook';
} else {
$activeNotebook = null;
$activeTag = null;
$viewMode = 'all';
}
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} catch (e) { } catch (e) {
console.error('Failed to open search result:', e); console.error('Failed to open search result:', e);
+31 -11
View File
@@ -583,7 +583,6 @@
} }
function restoreCurrentTheme() { function restoreCurrentTheme() {
// Re-apply the currently active theme to undo preview
const root = document.documentElement; const root = document.documentElement;
const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color']; const varsToClear = ['--bg-primary','--bg-secondary','--bg-tertiary','--bg-hover','--bg-active','--bg-editor','--text-primary','--text-secondary','--border-color'];
root.classList.remove('dark'); root.classList.remove('dark');
@@ -739,10 +738,16 @@
importResult = null; importResult = null;
importError = null; importError = null;
const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; error?: string }>('import-done', (event) => { const unlistenDone = await listen<{ success: boolean; files_converted?: number; links_converted?: number; frontmatter_normalized?: number; syntax_converted?: number; attachments_moved?: number; error?: string }>('import-done', (event) => {
const data = event.payload; const data = event.payload;
if (data.success) { if (data.success) {
importResult = { files_converted: data.files_converted ?? 0, links_converted: data.links_converted ?? 0 }; importResult = {
files_converted: data.files_converted ?? 0,
links_converted: data.links_converted ?? 0,
frontmatter_normalized: data.frontmatter_normalized ?? 0,
syntax_converted: data.syntax_converted ?? 0,
attachments_moved: data.attachments_moved ?? 0,
};
} else { } else {
importError = data.error ?? 'Import failed'; importError = data.error ?? 'Import failed';
} }
@@ -1807,7 +1812,7 @@
<div class="tab-content"> <div class="tab-content">
<div class="settings-section"> <div class="settings-section">
<h3>Import from Obsidian</h3> <h3>Import from Obsidian</h3>
<p class="import-desc">Convert Obsidian wiki-links (<code>![[image.png]]</code>, <code>[[note]]</code>) to standard markdown links across all notes in the current vault. This expects that you have opened an Obsidian vault directory directly as your HelixNotes vault.</p> <p class="import-desc">Fully converts an Obsidian vault to HelixNotes format: normalizes frontmatter (tags, IDs, dates, title headings), converts <code>[[wiki-links]]</code> to standard markdown links, converts <code>==highlights==</code> and <code>%%comments%%</code>, and relocates attachments to <code>.helixnotes/attachments/</code>. Open the Obsidian vault directory as your HelixNotes vault first.</p>
<div class="import-warn"> <div class="import-warn">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
<span>This modifies files in place. Make a backup before running!</span> <span>This modifies files in place. Make a backup before running!</span>
@@ -1826,12 +1831,21 @@
{/if} {/if}
</button> </button>
{#if importResult} {#if importResult}
<div class="import-result success"> <div class="import-result success">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 11-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
<span>Converted {importResult.links_converted} links across {importResult.files_converted} files</span> <div class="import-stats">
<span>Import complete</span>
<ul>
{#if importResult.frontmatter_normalized}<li>Normalized frontmatter in {importResult.frontmatter_normalized} notes</li>{/if}
{#if importResult.links_converted}<li>Converted {importResult.links_converted} wiki-links to markdown</li>{/if}
{#if importResult.syntax_converted}<li>Converted Obsidian syntax (highlights, comments) in {importResult.syntax_converted} notes</li>{/if}
{#if importResult.attachments_moved}<li>Moved {importResult.attachments_moved} attachments to .helixnotes/attachments/</li>{/if}
{#if !importResult.frontmatter_normalized && !importResult.links_converted && !importResult.syntax_converted && !importResult.attachments_moved}<li>No changes needed</li>{/if}
</ul>
</div> </div>
{/if} </div>
{/if}
{#if importError} {#if importError}
<div class="import-result error"> <div class="import-result error">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
@@ -2221,7 +2235,7 @@
Download from Website Download from Website
</button> </button>
{:else} {:else}
<a class="update-install-btn" href="https://codeberg.org/ArkHost/HelixNotes/releases" target="_blank" rel="noopener"> <a class="update-install-btn" href="https://gitlab.com/ArkHost/HelixNotes/-/releases" target="_blank" rel="noopener">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/> <path d="M18 13v6a2 2 0 01-2 2H5a2 2 0 01-2-2V8a2 2 0 012-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg> </svg>
@@ -2803,7 +2817,7 @@
.import-result { .import-result {
display: flex; display: flex;
align-items: center; align-items: flex-start;
gap: 8px; gap: 8px;
margin-top: 14px; margin-top: 14px;
padding: 10px 14px; padding: 10px 14px;
@@ -2816,6 +2830,12 @@
color: #059669; color: #059669;
} }
.import-stats ul {
margin: 4px 0 0 0;
padding-left: 16px;
line-height: 1.6;
}
.import-result.error { .import-result.error {
background: color-mix(in srgb, #e11d48 10%, transparent); background: color-mix(in srgb, #e11d48 10%, transparent);
color: #e11d48; color: #e11d48;
+32 -17
View File
@@ -146,6 +146,10 @@
const vault = $appConfig?.active_vault; const vault = $appConfig?.active_vault;
if (!vault) return; if (!vault) return;
if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') { if ($viewMode === 'notebook' && $activeNotebook?.relative_path === '') {
if (isMobile) {
onViewChanged();
return;
}
selectAllNotes(); selectAllNotes();
return; return;
} }
@@ -158,6 +162,10 @@
async function selectNotebook(nb: NotebookEntry) { async function selectNotebook(nb: NotebookEntry) {
// Deselect if clicking the already-active notebook; expand/collapse is the chevron's job // Deselect if clicking the already-active notebook; expand/collapse is the chevron's job
if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) { if ($viewMode === 'notebook' && $activeNotebook?.path === nb.path) {
if (isMobile) {
onViewChanged();
return;
}
selectAllNotes(); selectAllNotes();
return; return;
} }
@@ -800,12 +808,12 @@
class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''} class:active={$viewMode === 'notebook' && $activeNotebook?.relative_path === ''}
onclick={selectUnfiled} onclick={selectUnfiled}
> >
<span style="width:13px;flex-shrink:0"></span> <span style="width:14px;flex-shrink:0"></span>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="flex-shrink:0;opacity:0.6">
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" /> <path d="M22 12h-6l-2 3h-4l-2-3H2" />
<polyline points="14 2 14 8 20 8" /> <path d="M5.45 5.11 2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
</svg> </svg>
<span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span> <span class="notebook-name">Unfiled Notes <span class="notebook-count">{$rootNoteCount}</span></span>
</button> </button>
{/if} {/if}
{#each sortedNotebooks as nb (nb.path)} {#each sortedNotebooks as nb (nb.path)}
@@ -942,7 +950,7 @@
{@const isCollapsed = $collapsedNotebooks.includes(nb.path)} {@const isCollapsed = $collapsedNotebooks.includes(nb.path)}
{@const iconSrc = getNotebookIconSrc(nb)} {@const iconSrc = getNotebookIconSrc(nb)}
{#if editingNotebook === nb.path} {#if editingNotebook === nb.path}
<div class="notebook-item" style="padding-left: {8 + depth * 16}px"> <div class="notebook-item" style="padding-left: {4 + depth * 16}px">
<input <input
type="text" type="text"
class="rename-input" class="rename-input"
@@ -962,7 +970,7 @@
class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'} class:drop-target={dropTargetPath === nb.path && dropPosition === 'into'}
class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'} class:drop-above={dropTargetPath === nb.path && dropPosition === 'above'}
class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'} class:drop-below={dropTargetPath === nb.path && dropPosition === 'below'}
style="padding-left: {8 + depth * 16}px" style="padding-left: {4 + depth * 16}px"
data-nb-path={nb.path} data-nb-path={nb.path}
onclick={() => selectNotebook(nb)} onclick={() => selectNotebook(nb)}
onkeydown={(e) => { onkeydown={(e) => {
@@ -1028,13 +1036,20 @@
{:else} {:else}
<span class="chevron-spacer"></span> <span class="chevron-spacer"></span>
{/if} {/if}
{#if iconSrc} {#if iconSrc}
<img class="notebook-icon" src={iconSrc} alt="" /> <img class="notebook-icon" src={iconSrc} alt="" />
{:else} {:else if isCollapsed || nb.children.length === 0}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
<path d="M22 19a2 2 0 01-2 2H4a2 2 0 01-2-2V5a2 2 0 012-2h5l2 3h9a2 2 0 012 2z" /> <path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
</svg> <path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
{/if} </svg>
{:else}
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.6;flex-shrink:0">
<path d="M2 6h4"/><path d="M2 10h4"/><path d="M2 14h4"/><path d="M2 18h4"/>
<path d="M6 2h12a2 2 0 012 2v16a2 2 0 01-2 2H6a2 2 0 01-2-2V4a2 2 0 012-2z"/>
<path d="M10 9h8"/><path d="M10 13h8"/><path d="M10 17h5"/>
</svg>
{/if}
<span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span> <span class="notebook-name">{nb.name} <span class="notebook-count">{nb.note_count}</span></span>
{#if $notebookSortMode === 'manual'} {#if $notebookSortMode === 'manual'}
<!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events --> <!-- svelte-ignore a11y_no_static_element_interactions a11y_click_events_have_key_events -->
@@ -1139,7 +1154,7 @@
.section { .section {
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 4px 0; padding: 0 0 4px;
border-top: 1px solid var(--border-light); border-top: 1px solid var(--border-light);
} }
@@ -1295,7 +1310,7 @@
align-items: center; align-items: center;
gap: 6px; gap: 6px;
width: 100%; width: 100%;
padding: 5px 8px; padding: 5px 8px 5px 4px;
border: none; border: none;
background: none; background: none;
border-radius: 6px; border-radius: 6px;
+65 -8
View File
@@ -2,11 +2,13 @@
import { open } from '@tauri-apps/plugin-dialog'; import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window'; import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path'; import { documentDir } from '@tauri-apps/api/path';
import { openVault, removeVault, getAppConfig } from '$lib/api'; import { chooseExternalVault, getAppConfig, openVault, removeVault, restoreExternalVault } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app'; import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform'; import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types'; import type { VaultConfig } from '$lib/types';
let { initialError = '' }: { initialError?: string } = $props();
const appWindow = getCurrentWindow(); const appWindow = getCurrentWindow();
let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []); let recentVaults: VaultConfig[] = $derived($appConfig?.vaults ?? []);
@@ -15,6 +17,10 @@
let vaultName = $state('HelixNotes'); let vaultName = $state('HelixNotes');
let hasPermission = $state(!isAndroid); let hasPermission = $state(!isAndroid);
let selectedLocation = $state('Documents'); let selectedLocation = $state('Documents');
$effect(() => {
if (initialError && !error) error = initialError;
});
let customPath = $state(''); let customPath = $state('');
let iosBasePath = $state(''); let iosBasePath = $state('');
@@ -108,14 +114,57 @@
} }
} }
async function forgetVault(path: string) { async function pickExternalFolder() {
loading = true;
error = '';
try { try {
await removeVault(path); const selected = await chooseExternalVault();
if (!selected) return;
$appConfig = await getAppConfig(); $appConfig = await getAppConfig();
$vaultReady = true;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function openRecentVault(vault: VaultConfig) {
if (!isIOS || !vault.bookmark_id) {
await openSelectedVault(vault.path);
return;
}
loading = true;
error = '';
try {
await restoreExternalVault(vault.bookmark_id);
$appConfig = await getAppConfig();
$vaultReady = true;
} catch (e) {
error = String(e);
} finally {
loading = false;
}
}
async function forgetVault(vault: VaultConfig) {
try {
const wasActive = isActiveVault(vault);
await removeVault(vault.path, vault.bookmark_id);
$appConfig = await getAppConfig();
if (wasActive) $vaultReady = false;
} catch (e) { } catch (e) {
error = String(e); error = String(e);
} }
} }
function isActiveVault(vault: VaultConfig): boolean {
if (vault.bookmark_id) {
return vault.bookmark_id === $appConfig?.active_bookmark_id;
}
return !$appConfig?.active_bookmark_id && vault.path === $appConfig?.active_vault;
}
</script> </script>
<!-- svelte-ignore a11y_no_static_element_interactions --> <!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -146,7 +195,7 @@
<p class="subtitle">Local markdown notes</p> <p class="subtitle">Local markdown notes</p>
{#if isMobile} {#if isMobile}
{#if isIOS} {#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> <p class="description">Keep notes in HelixNotes, or use an existing folder from iCloud Drive or Files.</p>
{:else} {:else}
<p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p> <p class="description">Choose where to store your notes. Sync with Syncthing, Nextcloud, or any file sync app.</p>
{/if} {/if}
@@ -197,6 +246,8 @@
<button class="btn-primary" onclick={pickFolder} disabled={loading}> <button class="btn-primary" onclick={pickFolder} disabled={loading}>
{#if loading} {#if loading}
Opening... Opening...
{:else if isIOS}
Use Local Folder
{:else if isMobile} {:else if isMobile}
Get Started Get Started
{:else} {:else}
@@ -204,6 +255,12 @@
{/if} {/if}
</button> </button>
{#if isIOS}
<button class="btn-back" onclick={pickExternalFolder} disabled={loading}>
Choose Folder in Files
</button>
{/if}
{#if $appConfig?.active_vault} {#if $appConfig?.active_vault}
<button class="btn-back" onclick={() => ($vaultReady = true)}> <button class="btn-back" onclick={() => ($vaultReady = true)}>
Back Back
@@ -215,11 +272,11 @@
<span class="recent-label">Recent</span> <span class="recent-label">Recent</span>
{#each recentVaults as vault} {#each recentVaults as vault}
<div class="vault-row"> <div class="vault-row">
<button class="vault-item" class:current={vault.path === $appConfig?.active_vault} onclick={() => openSelectedVault(vault.path)}> <button class="vault-item" class:current={isActiveVault(vault)} onclick={() => openRecentVault(vault)}>
<span class="vault-name">{vault.name}{#if vault.path === $appConfig?.active_vault}<span class="vault-current-badge">Current</span>{/if}</span> <span class="vault-name">{vault.name}{#if isActiveVault(vault)}<span class="vault-current-badge">Current</span>{/if}</span>
<span class="vault-path">{vault.path}</span> <span class="vault-path">{vault.bookmark_id ? `Files · ${vault.path}` : vault.path}</span>
</button> </button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}> <button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault)}>
<svg width="12" height="12" viewBox="0 0 10 10"> <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="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" /> <line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
+1 -1
View File
@@ -163,7 +163,7 @@ export function serializeCallout(node: any, serializeChild: (child: any) => stri
while (parts.length && parts[parts.length - 1] === '') parts.pop(); while (parts.length && parts[parts.length - 1] === '') parts.pop();
const out = [`> ${header}`]; const out = [`> ${header}`];
const inner = parts.join('\n'); const inner = parts.join('\n\n');
if (inner.length) { if (inner.length) {
for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>'); for (const line of inner.split('\n')) out.push(line.length ? `> ${line}` : '>');
} }
+4 -1
View File
@@ -18,7 +18,10 @@ export const appConfig = writable<AppConfig | null>(null);
// The active vault's config entry (where per-vault WebDAV sync settings live). // The active vault's config entry (where per-vault WebDAV sync settings live).
export function activeVaultConfig(c: AppConfig | null): VaultConfig | null { export function activeVaultConfig(c: AppConfig | null): VaultConfig | null {
if (!c?.active_vault) return null; if (!c?.active_vault) return null;
return c.vaults.find((v) => v.path === c.active_vault) ?? null; if (c.active_bookmark_id) {
return c.vaults.find((v) => v.bookmark_id === c.active_bookmark_id) ?? null;
}
return c.vaults.find((v) => !v.bookmark_id && v.path === c.active_vault) ?? null;
} }
export const vaultReady = writable(false); export const vaultReady = writable(false);
+11
View File
@@ -44,6 +44,7 @@ export interface NoteContent {
export interface VaultConfig { export interface VaultConfig {
path: string; path: string;
name: string; name: string;
bookmark_id?: string | null;
// Per-vault WebDAV sync (moved off the global AppConfig). // Per-vault WebDAV sync (moved off the global AppConfig).
sync_provider?: string | null; sync_provider?: string | null;
webdav_url?: string | null; webdav_url?: string | null;
@@ -55,6 +56,12 @@ export interface VaultConfig {
last_sync_time?: string | null; last_sync_time?: string | null;
} }
export interface ExternalVaultResult {
bookmarkId: string;
path: string;
name: string;
}
export interface CustomThemeColors { export interface CustomThemeColors {
bg_primary: string; bg_primary: string;
bg_secondary: string; bg_secondary: string;
@@ -77,6 +84,7 @@ export interface CustomTheme {
export interface AppConfig { export interface AppConfig {
vaults: VaultConfig[]; vaults: VaultConfig[];
active_vault: string | null; active_vault: string | null;
active_bookmark_id?: string | null;
theme: string; theme: string;
accent_color: string | null; accent_color: string | null;
font_size: number | null; font_size: number | null;
@@ -171,6 +179,9 @@ export interface FileEvent {
export interface ImportResult { export interface ImportResult {
files_converted: number; files_converted: number;
links_converted: number; links_converted: number;
frontmatter_normalized: number;
syntax_converted: number;
attachments_moved: number;
} }
export interface VaultStats { export interface VaultStats {
+28
View File
@@ -0,0 +1,28 @@
function pathParts(path: string): string[] {
return path.replace(/\\/g, '/').split('/').filter(Boolean);
}
function pathRoot(path: string): string {
const normalized = path.replace(/\\/g, '/');
const drive = normalized.match(/^([A-Za-z]:)\//);
if (drive) return drive[1].toLowerCase();
return normalized.startsWith('/') ? '/' : '';
}
export function relativePath(fromDirectory: string, targetPath: string): string {
const from = pathParts(fromDirectory);
const target = pathParts(targetPath);
if (pathRoot(fromDirectory) !== pathRoot(targetPath)) return targetPath.replace(/\\/g, '/');
const windowsPath = /^[A-Za-z]:[\\/]/.test(fromDirectory) || /^[A-Za-z]:[\\/]/.test(targetPath);
const equal = windowsPath
? (left: string, right: string) => left.toLowerCase() === right.toLowerCase()
: (left: string, right: string) => left === right;
let common = 0;
while (common < from.length && common < target.length && equal(from[common], target[common])) {
common++;
}
const result = [...Array(from.length - common).fill('..'), ...target.slice(common)];
return result.join('/') || '.';
}
+24 -6
View File
@@ -1,8 +1,8 @@
<script lang="ts"> <script lang="ts">
import { onDestroy, onMount } from 'svelte'; import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app'; import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault, setFontSize } from '$lib/api'; import { getAppConfig, openVault, restoreExternalVault, setFontSize } from '$lib/api';
import { darkThemes } from '$lib/platform'; import { darkThemes, isIOS } from '$lib/platform';
import { getCurrentWebview } from '@tauri-apps/api/webview'; import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte'; import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte'; import AppLayout from '$lib/components/AppLayout.svelte';
@@ -10,6 +10,7 @@
let loading = $state(true); let loading = $state(true);
let noteWindowPath = $state<string | null>(null); let noteWindowPath = $state<string | null>(null);
let startupVaultError = $state('');
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null; let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null; let removeEditorZoomShortcuts: (() => void) | null = null;
@@ -204,14 +205,31 @@
// Auto-open last vault if available // Auto-open last vault if available
if (config.active_vault) { if (config.active_vault) {
const activeVault = config.active_bookmark_id
? config.vaults.find((vault) => vault.bookmark_id === config.active_bookmark_id)
: config.vaults.find(
(vault) => !vault.bookmark_id && vault.path === config.active_vault
);
try { try {
// Note windows don't re-open the vault (already open in main process) // Note windows don't re-open the vault (already open in main process)
if (!noteWindowPath) { if (!noteWindowPath) {
await openVault(config.active_vault); if (isIOS && activeVault?.bookmark_id) {
await restoreExternalVault(activeVault.bookmark_id);
$appConfig = await getAppConfig();
} else {
await openVault(config.active_vault);
}
} }
$vaultReady = true; $vaultReady = true;
} catch { } catch (e) {
// Vault path might not exist anymore if (isIOS && activeVault?.bookmark_id) {
startupVaultError = String(e);
$appConfig = {
...config,
active_vault: null,
active_bookmark_id: null
};
}
} }
} }
} catch { } catch {
@@ -235,7 +253,7 @@
{:else if $vaultReady} {:else if $vaultReady}
<AppLayout /> <AppLayout />
{:else} {:else}
<VaultPicker /> <VaultPicker initialError={startupVaultError} />
{/if} {/if}
<style> <style>