Compare commits

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

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

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

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

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

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

Co-authored-by: FS <failuresmith@proton.me>
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/108
2026-06-15 18:46:17 +02:00
blake 173e25dadc Add a copy button for code snippets (#107)
WIP: Copy button for code snippets
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/107
2026-06-12 22:31:59 +02:00
Yuri Karamian 2fa9458eab Trash: empty/restore/delete now reflects immediately instead of needing an app restart (don't serve the volatile trash view from cache) 2026-06-12 17:57:48 +02:00
Yuri Karamian 0a3b7a13e3 Restore 8 themes dropped by the #106 merge (blueberry, forest-green, gruvbox, midnight-tide, cherry-blossom, synthwave, ember, moonlit); re-add their app.css blocks 2026-06-12 17:25:17 +02:00
blake 50d27d5636 main (#106)
WIP: Themes, accents, dropdown
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/106
2026-06-12 17:19:14 +02:00
Yuri Karamian d050ac907f Bundle Lora, Merriweather, Literata & Open Sans (self-hosted woff2 + OFL) so the editor serif/sans fonts render correctly on Windows (#100) 2026-06-12 14:45:03 +02:00
Yuri Karamian 80ca411a08 WebDAV sync: match remote paths encoding-agnostically so Infomaniak kDrive (and other providers that vary percent-encoding case) sync instead of wiping local notes (#102) 2026-06-12 14:42:09 +02:00
Yuri Karamian 35894cdfd1 Android: editor font-size setting now applies - disable WebView text-autosizing (layoutAlgorithm NORMAL) + respect the font-size var (#100) 2026-06-12 10:24:14 +02:00
Yuri Karamian 4df96362f4 Android: disable WebView text-size-adjust font-boosting so the editor font-size setting applies (#100) 2026-06-12 09:36:56 +02:00
Yuri Karamian fc7d34612d Bump version to 1.3.1 2026-06-12 09:22:12 +02:00
Yuri Karamian 1b7dc77792 Fix note breadcrumb showing 'Unfiled Notes' on Windows: normalize backslash paths before deriving the note's folder (#99) 2026-06-12 09:11:26 +02:00
blake 5f311f2404 Add 8 dark themes and 5 accent presets (#101) (#101)
WIP: New themes, new accents.
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/101
2026-06-12 09:00:07 +02:00
Yuri Karamian f19c3c2caa Editor (#100): wrap long lines in Markdown source mode (no-wrap kept when line numbers are on) 2026-06-11 23:59:27 +02:00
Yuri Karamian 0f7b1debbb Editor: respect the font-size setting on Android (was hardcoded 16px); viewport already blocks auto-zoom so the hardcode was
unneeded (#100)
2026-06-11 23:45:32 +02:00
Yuri Karamian 4aa399e96e Fix desktop showing the mobile layout: decide layout from the actual build platform (cfg!(mobile)) instead of sniffing the webview user-agent, which some WebKitGTK builds report mobile-looking (#63) 2026-06-11 23:35:24 +02:00
Yuri Karamian 15c6e6388a Attachments: manual 'Find orphaned attachments' tool in the Info panel - scans all notes incl. trash, previews unreferenced files with sizes, moves to vault trash (recoverable); conservative raw+decoded matching, re-verified before moving, with a loading spinner (#52) 2026-06-11 23:16:59 +02:00
Yuri Karamian b501d2bd5c Notebooks: add a collapse-all / expand-all button to the sidebar tree header (#26) 2026-06-11 22:56:54 +02:00
Yuri Karamian fb4c5748a8 AI: OpenAI-compatible provider now uses /v1/chat/completions (not the legacy /v1/completions) so Nvidia NIM and similar work; tolerate a trailing /v1 in the base URL (#96) 2026-06-11 22:36:03 +02:00
Yuri Karamian d9cb615f1f Tasks: add a free-text filter box (matches task text or note title) (#95) 2026-06-11 22:25:51 +02:00
Yuri Karamian 0e09d1f9f1 Notebooks: fix drag-to-reorder and drag-out-to-root by deciding the drop action from the drop coordinates instead of cleared dragover state (#97) 2026-06-11 22:07:41 +02:00
Yuri Karamian db924f7352 Disable in-app updater for repo builds (HELIXNOTES_INSTALL_TYPE), open tel:/sms: links in the system handler (#98), label AI button \"OpenAI Compatible\" 2026-06-11 21:24:42 +02:00
Yuri Karamian 2fea44bde6 Vault picker: remove a vault from the Recent list (#93); fix Tasks view showing "All Notes" as the mobile header title 2026-06-11 20:48:20 +02:00
Yuri Karamian 784248f984 Tasks: per-vault persistence for the Flagged filter, Hide done, and sort mode (issue #94) 2026-06-11 15:50:18 +02:00
ArkHost ee16ef07c5 Merge pull request 'Update README.md - Add Solus install instructions' (#92) from clintre/HelixNotes:main into main
Reviewed-on: https://codeberg.org/ArkHost/HelixNotes/pulls/92
2026-06-11 09:12:03 +02:00
clintre 3648a2dc90 Update README.md - Add Solus install 2026-06-11 01:41:47 +02:00
Yuri Karamian 8f250d1ab9 Update README.md 2026-06-10 22:44:27 +02:00
Yuri Karamian 67d5ae87c3 Daily note title format setting (Localized/ISO/Long/US/EU); filenames stay ISO (#40) 2026-06-10 20:44:29 +02:00
Yuri Karamian 08abbe25ef update README 2026-06-10 18:03:50 +02:00
52 changed files with 3283 additions and 817 deletions
+26 -15
View File
@@ -1,7 +1,7 @@
# HelixNotes
[![License: AGPL-3.0](https://img.shields.io/badge/License-AGPL--3.0-blue.svg)](https://codeberg.org/ArkHost/HelixNotes/src/branch/main/LICENSE)
[![Latest Release](https://img.shields.io/badge/release-v1.2.9-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Latest Release](https://img.shields.io/badge/release-v1.3.0-green)](https://codeberg.org/ArkHost/HelixNotes/releases)
[![Website](https://img.shields.io/badge/web-helixnotes.com-purple)](https://helixnotes.com)
[![Platform](https://img.shields.io/badge/platform-Linux%20%7C%20Windows%20%7C%20macOS%20%7C%20Android-lightgrey)]()
@@ -10,7 +10,7 @@ A local markdown note-taking app built with Tauri, SvelteKit, and Rust.
Your notes are stored as standard Markdown files on your local filesystem.
No cloud, no lock-in.
## Download (v1.2.9)
## Download (v1.3.0)
### Linux
@@ -26,6 +26,12 @@ yay -S helixnotes-appimage-bin
curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/keyrings/arkhost.gpg && echo "deb [signed-by=/usr/share/keyrings/arkhost.gpg arch=amd64] https://repo.arkhost.com stable main" | sudo tee /etc/apt/sources.list.d/helixnotes.list && sudo apt update && sudo apt install helix-notes
```
#### Solus (EOPKG)
```bash
sudo eopkg it helixnotes
```
#### NixOS
<details>
@@ -86,23 +92,23 @@ curl -fsSL https://repo.arkhost.com/gpg.key | sudo gpg --dearmor -o /usr/share/k
#### AppImage (Arch, Fedora 43+, openSUSE Tumbleweed)
[Download AppImage](https://download.helixnotes.com/releases/v1.2.9/HelixNotes_1.2.9_amd64.AppImage)
[Download AppImage](https://download.helixnotes.com/releases/v1.3.0/HelixNotes_1.3.0_amd64.AppImage)
#### .deb (manual)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_amd64.deb) (Ubuntu 22.04+)
[Download .deb](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_amd64.deb) (Ubuntu 22.04+)
### Windows
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64-setup.exe) (Windows 10/11)
[Download Installer](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64-setup.exe) (Windows 10/11)
### macOS
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
[Download .dmg](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_x64.dmg) (Intel, runs on Apple Silicon via Rosetta)
### Android
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.2.9/HelixNotes_1.2.9_android.apk)
[Download APK](https://codeberg.org/ArkHost/HelixNotes/releases/download/v1.3.0/HelixNotes_1.3.0_android.apk)
---
@@ -111,19 +117,23 @@ All releases: [codeberg.org/ArkHost/HelixNotes/releases](https://codeberg.org/Ar
## Features
- Markdown editor with toolbar, slash commands, source mode, code highlighting
- **Tasks view**: aggregate `- [ ]` checklists from across all notes, set priority and due dates, work in a list or a calendar (drag a task to reschedule)
- `[[Wiki-links]]` and graph view
- Full-text search (Tantivy)
- Outline panel, daily notes with calendar view, tags, drag-and-drop
- Full-text search (Tantivy), CJK-aware for Chinese, Japanese, and Korean
- Outline panel, daily notes with calendar view, tags with autocomplete, drag-and-drop
- Live KaTeX math editor (`/math`, `/imath`) with modal preview, double-click to edit
- Mermaid diagrams (opt-in render, copy as PNG, save as PNG/SVG)
- Encrypted secret blocks (`/secret`) stored as portable `helix-secret` markdown fences
- Insert date/time (`/date`, `/time`, `/now`), color swatches (`/color`), configurable week start
- Manual notebook sorting (drag to reorder above, into, or below)
- External `.md` viewer mode with import-to-vault flow
- PDF preview, Obsidian import, "Show in File Manager"
- AI writing tools (Ollama / Anthropic / OpenAI)
- AI writing tools (Ollama / OpenAI-compatible / Anthropic / OpenAI)
- **Optional WebDAV sync** to your own server (Nextcloud, ownCloud, a NAS): manual or automatic, with keep-both conflict copies
- Version history with diffs, automatic backups
- Multi-window, file associations, focus mode, view mode
- Themes, accent colors, fonts
- Local files, no cloud
- Themes (light, dark, and 14 palettes), accent colors, fonts, 80-200% interface scale
- Local plain-text files, no company cloud
Full documentation: [helixnotes.com/docs](https://helixnotes.com/docs.html)
@@ -158,9 +168,10 @@ pnpm tauri build
## Screenshots
![Editor](https://cdn.helixnotes.com/assets/screenshots/screenshot-1.png)
![Settings](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph View](https://cdn.helixnotes.com/assets/screenshots/screenshot-3.png)
![AI Actions](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
![Tasks calendar](https://cdn.helixnotes.com/assets/screenshots/screenshot-2.png)
![Graph view](https://cdn.helixnotes.com/assets/screenshots/screenshot-7.png)
![Daily notes](https://cdn.helixnotes.com/assets/screenshots/screenshot-4.png)
![Themes](https://cdn.helixnotes.com/assets/screenshots/screenshot-6.png)
## License
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "helixnotes",
"private": true,
"license": "AGPL-3.0-or-later",
"version": "1.3.0",
"version": "1.3.1",
"type": "module",
"scripts": {
"dev": "vite dev",
+1 -1
View File
@@ -1850,7 +1850,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "helixnotes"
version = "1.3.0"
version = "1.3.1"
dependencies = [
"arboard",
"chrono",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "helixnotes"
version = "1.3.0"
version = "1.3.1"
description = "Local markdown note-taking app"
authors = ["HelixNotes"]
license = "AGPL-3.0-or-later"
@@ -26,6 +26,13 @@ class MainActivity : TauriActivity() {
super.onWebViewCreate(webView)
this.webView = webView
webView.addJavascriptInterface(StorageBridge(this), "Android")
// Render text at the size CSS specifies, ignoring the device's system font-size
// accessibility setting (textZoom is otherwise tied to the system font scale). (#100)
webView.settings.textZoom = 100
// The actual fix: disable WebView "text autosizing" / font-boosting, which recomputes
// paragraph font-size and line-height from viewport heuristics and ignores our CSS (only
// size + line-height were affected; font-family was fine). NORMAL turns it off. (#100)
webView.settings.layoutAlgorithm = android.webkit.WebSettings.LayoutAlgorithm.NORMAL
}
override fun onResume() {
+11 -165
View File
@@ -55,8 +55,8 @@ pub fn ai_request(
if url.is_empty() {
Err("No base URL configured for OpenAI Compatible provider".to_string())
} else {
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
stream_completions(
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
stream_openai(
&app,
&url,
key_opt,
@@ -94,6 +94,13 @@ pub fn ai_request(
});
}
/// Normalize an OpenAI-compatible base URL: drop a trailing slash and a trailing `/v1`,
/// so both `https://host` and `https://host/v1` work (we append `/v1/chat/completions`).
fn normalize_openai_base(base: &str) -> String {
let b = base.trim().trim_end_matches('/');
b.strip_suffix("/v1").unwrap_or(b).trim_end_matches('/').to_string()
}
async fn stream_anthropic(
app: &AppHandle,
api_key: &str,
@@ -386,8 +393,8 @@ pub async fn test_connection(
if url.is_empty() {
return Err("No base URL configured for OpenAI Compatible provider".to_string());
}
let url = format!("{}/v1/completions", url.trim_end_matches('/'));
test_completions(&url, key_opt, model).await
let url = format!("{}/v1/chat/completions", normalize_openai_base(url));
test_openai(&url, key_opt, model).await
}
_ => test_anthropic(api_key, model).await,
}
@@ -426,167 +433,6 @@ async fn test_anthropic(api_key: &str, model: &str) -> Result<String, String> {
}
}
async fn stream_completions(
app: &AppHandle,
url: &str,
api_key: Option<&str>,
model: &str,
system_prompt: &str,
user_message: &str,
_request_id: &str,
) -> Result<(), String> {
let client = Client::new();
let prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, user_message);
let body = json!({
"model": model,
"prompt": prompt,
"max_tokens": 4096,
"stream": true,
"temperature": 0.7
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Request failed: {}", e))?;
if !response.status().is_success() {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
return Err(format!("API error {}: {}", status, body_text));
}
// Parse SSE stream
use futures::StreamExt;
let mut stream = response.bytes_stream();
let mut buffer = String::new();
while let Some(chunk) = stream.next().await {
let chunk = chunk.map_err(|e| format!("Stream error: {}", e))?;
buffer.push_str(&String::from_utf8_lossy(&chunk));
// Process complete SSE events from buffer
while let Some(event_end) = buffer.find("\n\n") {
let event_str = buffer[..event_end].to_string();
buffer = buffer[event_end + 2..].to_string();
for line in event_str.lines() {
if let Some(data) = line.strip_prefix("data: ") {
if data == "[DONE]" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(data) {
// Completions streaming: choices[0].text
if let Some(text) = parsed["choices"][0]["text"].as_str() {
if !text.is_empty() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "text".to_string(),
text: Some(text.to_string()),
error: None,
},
);
}
}
// Check finish_reason
if let Some(reason) = parsed["choices"][0]["finish_reason"].as_str() {
if reason == "stop" || reason == "length" {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
return Ok(());
}
}
// Check for error in stream
if let Some(err) = parsed["error"]["message"].as_str() {
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "error".to_string(),
text: None,
error: Some(err.to_string()),
},
);
return Err(err.to_string());
}
}
}
}
}
}
let _ = app.emit(
"ai-stream",
AiStreamEvent {
event_type: "done".to_string(),
text: None,
error: None,
},
);
Ok(())
}
async fn test_completions(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
let body = json!({
"model": model,
"prompt": "Hi",
"max_tokens": 10,
});
let mut req = client
.post(url)
.header("content-type", "application/json");
if let Some(key) = api_key {
req = req.header("Authorization", format!("Bearer {}", key));
}
let response = req
.json(&body)
.send()
.await
.map_err(|e| format!("Connection failed: {}", e))?;
if response.status().is_success() {
Ok("Connection successful".to_string())
} else {
let status = response.status();
let body_text = response.text().await.unwrap_or_default();
Err(format!("API error {}: {}", status, body_text))
}
}
async fn test_openai(url: &str, api_key: Option<&str>, model: &str) -> Result<String, String> {
let client = Client::new();
+141 -1
View File
@@ -51,6 +51,17 @@ pub fn open_vault(app: AppHandle, state: State<'_, AppState>, path: String) -> R
Ok(())
}
#[tauri::command]
pub fn remove_vault(state: State<'_, AppState>, path: String) -> Result<(), String> {
let mut config = state.config.lock().map_err(|e| e.to_string())?;
config.vaults.retain(|v| v.path != path);
if config.active_vault.as_deref() == Some(path.as_str()) {
config.active_vault = None;
}
save_app_config(&config)?;
Ok(())
}
#[tauri::command]
pub fn get_app_config(state: State<'_, AppState>) -> Result<AppConfig, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
@@ -300,7 +311,7 @@ pub fn create_note(
pub fn create_daily_note(state: State<'_, AppState>, date: Option<String>) -> Result<NoteEntry, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let entry = operations::create_daily_note(vault_path, date.as_deref())?;
let entry = operations::create_daily_note(vault_path, date.as_deref(), &config.daily_title_format)?;
if let Ok(search_guard) = state.search_index.lock() {
if let Some(ref search) = *search_guard {
@@ -1068,6 +1079,7 @@ pub fn set_general_settings(
compact_notes: bool,
time_format: String,
week_start: String,
daily_title_format: String,
gpu_acceleration: bool,
autostart: bool,
pdf_preview: bool,
@@ -1088,6 +1100,7 @@ pub fn set_general_settings(
config.restore_last_session = restore_last_session;
config.time_format = time_format;
config.week_start = week_start;
config.daily_title_format = daily_title_format;
config.gpu_acceleration = gpu_acceleration;
config.autostart = autostart;
config.pdf_preview = pdf_preview;
@@ -1645,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
Ok(result.to_string_lossy().to_string())
}
// ── Orphaned attachment cleanup ──
#[derive(serde::Serialize)]
pub struct OrphanAttachment {
pub name: String,
pub size: u64,
}
// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every
// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files)
// and matches each filename against both the raw note text and a percent-decoded copy (so a
// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is
// KEPT: a leftover orphan is harmless, a wrong deletion is not.
fn scan_orphaned_attachments(vault: &str) -> Result<Vec<(String, u64)>, String> {
let attachments_dir = operations::helixnotes_dir(vault).join("attachments");
if !attachments_dir.is_dir() {
return Ok(Vec::new());
}
let mut files: Vec<(String, u64)> = Vec::new();
for entry in std::fs::read_dir(&attachments_dir).map_err(|e| e.to_string())? {
let entry = entry.map_err(|e| e.to_string())?;
let p = entry.path();
if p.is_file() {
let name = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
files.push((name, size));
}
}
if files.is_empty() {
return Ok(Vec::new());
}
let mut haystack = String::new();
for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) {
let p = entry.path();
if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") {
if let Ok(content) = std::fs::read_to_string(p) {
haystack.push_str(&content);
haystack.push('\n');
}
}
}
let decoded = percent_decode(&haystack);
let orphans = files
.into_iter()
.filter(|(name, _)| !haystack.contains(name.as_str()) && !decoded.contains(name.as_str()))
.collect();
Ok(orphans)
}
#[tauri::command]
pub fn find_orphaned_attachments(
state: State<'_, AppState>,
) -> Result<Vec<OrphanAttachment>, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
let orphans = scan_orphaned_attachments(&vault)?;
Ok(orphans
.into_iter()
.map(|(name, size)| OrphanAttachment { name, size })
.collect())
}
#[tauri::command]
pub fn trash_orphaned_attachments(
state: State<'_, AppState>,
names: Vec<String>,
) -> Result<u32, String> {
let vault = {
let config = state.config.lock().map_err(|e| e.to_string())?;
config.active_vault.clone().ok_or("No active vault")?
};
// Re-scan now and only move files that are STILL orphaned (guards against a reference added
// between scan and confirm), intersected with the caller's selection.
let current: std::collections::HashSet<String> = scan_orphaned_attachments(&vault)?
.into_iter()
.map(|(n, _)| n)
.collect();
let attachments_dir = operations::helixnotes_dir(&vault).join("attachments");
let trash_dir = operations::helixnotes_dir(&vault).join("trash");
std::fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?;
let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string();
let mut moved = 0u32;
for (i, name) in names.iter().enumerate() {
if !current.contains(name) {
continue;
}
// Path-traversal guard: only act on a bare filename inside the attachments dir.
if std::path::Path::new(name)
.file_name()
.map(|f| f.to_string_lossy().to_string())
.as_deref()
!= Some(name.as_str())
{
continue;
}
let src = attachments_dir.join(name);
if !src.is_file() {
continue;
}
let dest = trash_dir.join(format!("{}_{}_attachment_{}", stamp, i, name));
if std::fs::rename(&src, &dest).is_ok() {
moved += 1;
}
}
Ok(moved)
}
fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len());
let mut chars = s.bytes();
@@ -1727,8 +1849,12 @@ fn xdg_open(arg: &str) -> Result<(), String> {
}
#[cfg(target_os = "windows")]
{
use std::os::windows::process::CommandExt;
// CREATE_NO_WINDOW: open the URL/path without flashing a console window.
const CREATE_NO_WINDOW: u32 = 0x0800_0000;
std::process::Command::new("cmd")
.args(["/C", "start", "", arg])
.creation_flags(CREATE_NO_WINDOW)
.spawn()
.map_err(|e| format!("Failed to open {}: {}", arg, e))?;
}
@@ -2238,8 +2364,22 @@ fn save_app_config(config: &AppConfig) -> Result<(), String> {
// ── Install Type Detection ──
#[tauri::command]
pub fn is_mobile_platform() -> bool {
// Compile-time platform: true only for the Android/iOS builds. Authoritative, unlike the
// webview user-agent, which some desktop WebKitGTK builds report mobile-looking (issue #63).
cfg!(mobile)
}
#[tauri::command]
pub fn get_install_type() -> String {
// Build-time override for distro packagers (e.g. Solus): build with
// HELIXNOTES_INSTALL_TYPE=solus to report that type and suppress the in-app updater.
if let Some(forced) = option_env!("HELIXNOTES_INSTALL_TYPE") {
if !forced.is_empty() {
return forced.to_string();
}
}
if cfg!(target_os = "macos") {
"macos".to_string()
} else if cfg!(target_os = "windows") {
+4
View File
@@ -98,6 +98,7 @@ pub fn run() {
.manage(app_state)
.invoke_handler(tauri::generate_handler![
commands::open_vault,
commands::remove_vault,
commands::get_app_config,
commands::set_theme,
commands::set_accent_color,
@@ -147,6 +148,8 @@ pub fn run() {
commands::remove_quick_access,
commands::reorder_quick_access,
commands::get_vault_stats,
commands::find_orphaned_attachments,
commands::trash_orphaned_attachments,
commands::import_obsidian,
commands::open_file,
commands::open_url,
@@ -168,6 +171,7 @@ pub fn run() {
commands::test_sync_connection,
commands::sync_now,
commands::get_install_type,
commands::is_mobile_platform,
commands::get_pending_open_file,
])
.register_asynchronous_uri_scheme_protocol("imgproxy", |_ctx, request, responder| {
+50 -10
View File
@@ -350,20 +350,46 @@ impl WebdavClient {
}
}
/// Map a WebDAV `href` to a vault-relative path (forward slash). Returns None for
/// the base collection itself.
fn href_to_relpath(href: &str, base_path: &str) -> Option<String> {
let path = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href).ok()?.path().to_string()
/// Map a WebDAV `href` to a vault-relative path (forward slash).
/// `Ok(None)` is the base collection itself. `Err` means the href could not be mapped
/// (e.g. it does not sit under the configured base path, as some providers like Infomaniak
/// kDrive return). Callers MUST treat `Err` as a hard failure: silently dropping an
/// unmappable href makes that file look deleted on the remote, and the reconciler then
/// wipes the local copy. (issue #102)
fn href_to_relpath(href: &str, base_path: &str) -> Result<Option<String>, String> {
// Pull out just the path component (some servers return absolute-URL hrefs).
let raw = if href.starts_with("http://") || href.starts_with("https://") {
reqwest::Url::parse(href)
.map_err(|e| format!("invalid remote href URL '{href}': {e}"))?
.path()
.to_string()
} else {
href.to_string()
};
let path = path.trim_end_matches('/');
let rest = path.strip_prefix(base_path)?.trim_start_matches('/');
// Compare DECODED paths, not the raw percent-encoded strings. Providers differ in how they
// encode hrefs: Infomaniak kDrive lowercases the hex (`%5b`/`%5d`) and encodes spaces, while
// reqwest emits uppercase hex (`%5B`/`%5D`) for base_path. A raw strip_prefix then spuriously
// fails, every remote file looks deleted, and the reconciler wipes the local copies. Decoding
// both sides first makes the prefix match encoding-agnostic. (issue #102)
let path = urlencoding::decode(raw.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in remote path '{raw}': {e}"))?
.into_owned();
let base = urlencoding::decode(base_path.trim_end_matches('/'))
.map_err(|e| format!("bad percent-encoding in WebDAV base path '{base_path}': {e}"))?
.into_owned();
let rest = match path.strip_prefix(&base) {
Some(r) => r.trim_start_matches('/'),
None => {
return Err(format!(
"remote path '{path}' is not under the configured WebDAV folder '{base}'. \
Aborting before deleting anything locally - please report this URL."
))
}
};
if rest.is_empty() {
return None;
return Ok(None); // the base collection itself
}
Some(urlencoding::decode(rest).ok()?.into_owned())
Ok(Some(rest.to_string()))
}
fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, String> {
@@ -427,7 +453,7 @@ fn parse_multistatus(xml: &str, base_path: &str) -> Result<Vec<RemoteEntry>, Str
}
b"response" => {
if let Some(h) = href.take() {
if let Some(rel) = href_to_relpath(&h, base_path) {
if let Some(rel) = href_to_relpath(&h, base_path)? {
entries.push(RemoteEntry {
relpath: rel,
etag: etag.take().map(|s| normalize_etag(&s)).unwrap_or_default(),
@@ -642,6 +668,20 @@ pub fn run_sync(app: tauri::AppHandle, vault: String, cfg: WebdavConfig) -> Resu
let local = scan_local(&vault)?;
let manifest = load_manifest(&vault);
// Safety guard (issue #102): never treat an empty remote listing as "everything was
// deleted remotely". If a prior sync exists (non-empty manifest) but the remote came back
// empty, it is almost certainly a listing failure (e.g. a provider whose paths we can't
// map), not a real mass-deletion - so abort instead of wiping local notes.
if remote.is_empty() && !manifest.files.is_empty() {
return Err(
"Sync aborted to protect your notes: the WebDAV server returned an empty file list \
even though files were synced before, so no local files were deleted. This usually \
means the server reports paths in a format the app can't read yet - please report \
your WebDAV provider and folder URL."
.to_string(),
);
}
let _ = app.emit("sync-progress", serde_json::json!({ "status": "syncing" }));
// Mute the watcher across local writes so a pull doesn't storm the reindex path.
state.importing.store(true, Ordering::Relaxed);
+16
View File
@@ -79,6 +79,8 @@ pub struct AppConfig {
pub time_format: String,
#[serde(default = "default_week_start")]
pub week_start: String,
#[serde(default = "default_daily_title_format")]
pub daily_title_format: String,
#[serde(default)]
pub gpu_acceleration: bool,
#[serde(default)]
@@ -178,6 +180,10 @@ fn default_week_start() -> String {
"monday".to_string()
}
fn default_daily_title_format() -> String {
"localized".to_string()
}
fn default_max_versions() -> u32 {
20
}
@@ -201,6 +207,7 @@ impl Default for AppConfig {
show_note_dates: true,
time_format: "relative".to_string(),
week_start: "monday".to_string(),
daily_title_format: "localized".to_string(),
gpu_acceleration: true,
autostart: false,
pdf_preview: false,
@@ -263,6 +270,12 @@ pub struct VaultState {
pub last_tag: Option<String>,
#[serde(default)]
pub tasks_layout: String,
#[serde(default = "default_true")]
pub tasks_hide_completed: bool,
#[serde(default)]
pub tasks_only_flagged: bool,
#[serde(default)]
pub tasks_sort: String,
}
impl Default for VaultState {
@@ -280,6 +293,9 @@ impl Default for VaultState {
last_notebook: None,
last_tag: None,
tasks_layout: String::new(),
tasks_hide_completed: true,
tasks_only_flagged: false,
tasks_sort: String::new(),
}
}
}
+15 -3
View File
@@ -520,15 +520,27 @@ fn get_system_locale() -> Locale {
}
}
pub fn create_daily_note(vault_path: &str, date: Option<&str>) -> Result<NoteEntry, String> {
pub fn create_daily_note(
vault_path: &str,
date: Option<&str>,
format: &str,
) -> Result<NoteEntry, String> {
let target_date = match date {
Some(d) => chrono::NaiveDate::parse_from_str(d, "%Y-%m-%d")
.map_err(|e| format!("Invalid date: {}", e))?,
None => Local::now().date_naive(),
};
let date_str = target_date.format("%Y-%m-%d").to_string();
let locale = get_system_locale();
let title = target_date.format_localized("%B %d, %Y", locale).to_string();
let title = match format {
"iso" => target_date.format("%Y-%m-%d").to_string(),
"long" => target_date.format("%B %-d, %Y").to_string(),
"us" => target_date.format("%m/%d/%Y").to_string(),
"eu" => target_date.format("%d/%m/%Y").to_string(),
_ => {
let locale = get_system_locale();
target_date.format_localized("%B %d, %Y", locale).to_string()
}
};
let dir = Path::new(vault_path).join("Daily");
fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "HelixNotes",
"version": "1.3.0",
"version": "1.3.1",
"identifier": "com.helixnotes.app",
"build": {
"frontendDist": "../build",
+870 -389
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -20,6 +20,10 @@ export async function openVault(path: string): Promise<void> {
return invoke("open_vault", { path });
}
export async function removeVault(path: string): Promise<void> {
return invoke("remove_vault", { path });
}
export async function getAppConfig(): Promise<AppConfig> {
return invoke("get_app_config");
}
@@ -216,6 +220,7 @@ export async function setGeneralSettings(
compactNotes: boolean,
timeFormat: string,
weekStart: string,
dailyTitleFormat: string,
gpuAcceleration: boolean,
autostart: boolean,
pdfPreview: boolean,
@@ -234,6 +239,7 @@ export async function setGeneralSettings(
compactNotes,
timeFormat,
weekStart,
dailyTitleFormat,
gpuAcceleration,
autostart,
pdfPreview,
@@ -270,6 +276,19 @@ export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats");
}
export interface OrphanAttachment {
name: string;
size: number;
}
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
return invoke("find_orphaned_attachments");
}
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
return invoke("trash_orphaned_attachments", { names });
}
export async function importObsidian(): Promise<void> {
return invoke("import_obsidian");
}
@@ -444,6 +463,10 @@ export async function getInstallType(): Promise<string> {
return invoke("get_install_type");
}
export async function isMobilePlatform(): Promise<boolean> {
return invoke("is_mobile_platform");
}
export async function getPendingOpenFile(): Promise<string | null> {
return invoke("get_pending_open_file");
}
+20 -7
View File
@@ -38,6 +38,9 @@
viewMode,
sortMode,
tasksLayout,
tasksHideCompleted,
tasksOnlyFlagged,
tasksSort,
activeTag,
updateAvailable as globalUpdateAvailable,
settingsTab,
@@ -45,12 +48,13 @@
viewerNote,
notebookSortMode,
notebookOrder,
syncState
syncState,
platformIsMobile
} from '$lib/stores/app';
const appWindow = getCurrentWindow();
const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isMobile = $derived($platformIsMobile);
const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
@@ -202,7 +206,10 @@
last_view_mode: $viewMode,
last_notebook: $activeNotebook?.relative_path ?? null,
last_tag: $activeTag,
tasks_layout: $tasksLayout
tasks_layout: $tasksLayout,
tasks_hide_completed: $tasksHideCompleted,
tasks_only_flagged: $tasksOnlyFlagged,
tasks_sort: $tasksSort
};
try {
await saveVaultState(state);
@@ -323,7 +330,7 @@
let historyDepth = 0;
let navFromPopstate = false;
if (isMobile) {
if (get(platformIsMobile)) {
history.replaceState({ mobileView: 'sidebar', depth: 0 }, '');
$effect(() => {
@@ -468,8 +475,8 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -504,6 +511,9 @@
$effect(() => {
$sortMode;
$tasksLayout;
$tasksHideCompleted;
$tasksOnlyFlagged;
$tasksSort;
persistState();
});
@@ -529,6 +539,9 @@
$notebookOrder = state.notebook_order ?? {};
if (state.sort_mode === 'created' || state.sort_mode === 'title' || state.sort_mode === 'modified') $sortMode = state.sort_mode;
if (state.tasks_layout === 'calendar' || state.tasks_layout === 'list') $tasksLayout = state.tasks_layout;
if (typeof state.tasks_hide_completed === 'boolean') $tasksHideCompleted = state.tasks_hide_completed;
if (typeof state.tasks_only_flagged === 'boolean') $tasksOnlyFlagged = state.tasks_only_flagged;
if (state.tasks_sort === 'due' || state.tasks_sort === 'priority' || state.tasks_sort === 'note') $tasksSort = state.tasks_sort;
lastNotePath = state.last_open_note ?? null;
lastViewMode = state.last_view_mode ?? '';
lastNotebook = state.last_notebook ?? null;
@@ -712,7 +725,7 @@
{#if $mobileView === 'sidebar'}
HelixNotes
{:else}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{#if $viewMode === 'notebook'}{$activeNotebook?.name ?? 'Notebook'}{:else if $viewMode === 'tag'}#{$activeTag}{:else if $viewMode === 'quickaccess'}Quick Access{:else if $viewMode === 'daily'}Daily Notes{:else if $viewMode === 'tasks'}Tasks{:else if $viewMode === 'trash'}Trash{:else}All Notes{/if}
{/if}
</span>
{#if $globalUpdateAvailable && $mobileView === 'sidebar'}
+2 -2
View File
@@ -142,8 +142,8 @@
}
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
File diff suppressed because it is too large Load Diff
+108 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api';
import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types';
@@ -11,6 +12,10 @@
let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -35,8 +40,26 @@
getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else {
stats = null;
orphans = null;
}
});
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script>
{#if $showInfo}
@@ -103,6 +126,25 @@
</div>
{/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p>
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
@@ -350,6 +392,71 @@
.info-link:hover {
background: var(--accent-light);
}
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section {
width: 100%;
+3 -1
View File
@@ -310,7 +310,9 @@
}
const key = cacheKey();
const cached = noteCache.get(key);
// Never serve the trash view from cache: emptying / restoring / deleting changes it,
// and a stale cache left it showing cleared items until an app restart. Always re-read.
const cached = $viewMode === 'trash' ? undefined : noteCache.get(key);
if (cached) {
$notes = cached;
return;
+2 -2
View File
@@ -35,8 +35,8 @@
$effect(() => {
const t = $theme || 'system';
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
+285 -118
View File
@@ -1,5 +1,5 @@
<script lang="ts">
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode } from '$lib/stores/app';
import { showSettings, theme, appConfig, updateAvailable as globalUpdateAvailable, updateObj as globalUpdateObj, installType, settingsTab, vaultReady, androidApkUrl, checkForUpdateMobile, notebookSortMode, isManagedInstall } from '$lib/stores/app';
import { setTheme, setAccentColor, setFontSize, setFontFamily, setLineHeight, setUiScale, setGeneralSettings, importObsidian, createBackup, listBackups, restoreBackup, deleteBackup, setBackupSettings, setAiSettings, testAiConnection, setSyncSettings, testSyncConnection, syncNow } from '$lib/api';
import { open as openDialog } from '@tauri-apps/plugin-dialog';
import { listen } from '@tauri-apps/api/event';
@@ -384,7 +384,7 @@
}
}
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
let isThemeDark = $derived(
darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches)
);
@@ -400,6 +400,20 @@
{ id: 'github-light', label: 'GitHub Light', bg: '#ffffff', sidebar: '#f6f8fa', accent: '#0969da' },
{ id: 'github-dark', label: 'GitHub Dark', bg: '#0d1117', sidebar: '#161b22', accent: '#58a6ff' },
{ id: 'dracula', label: 'Dracula', bg: '#282a36', sidebar: '#21222c', accent: '#bd93f9' },
{ id: 'midnight-tide', label: 'Midnight Tide', bg: '#020122', sidebar: '#050a38', accent: '#2dd4bf' },
{ id: 'blueberry', label: 'Blueberry', bg: '#1a1a2e', sidebar: '#16213e', accent: '#7b9cff' },
{ id: 'forest-green', label: 'Forest Green', bg: '#1a2318', sidebar: '#1f2b1c', accent: '#6dbf67' },
{ id: 'gruvbox', label: 'Gruvbox', bg: '#282828', sidebar: '#1d2021', accent: '#fabd2f' },
{ id: 'cherry-blossom', label: 'Cherry Blossom', bg: '#1a0e12', sidebar: '#221218', accent: '#f4a0b5' },
{ id: 'synthwave', label: 'Synthwave', bg: '#0d0221', sidebar: '#130332', accent: '#ff6ac1' },
{ id: 'ember', label: 'Ember', bg: '#0f0a06', sidebar: '#1a1008', accent: '#ff8c42' },
{ id: 'moonlit', label: 'Moonlit', bg: '#0a0d12', sidebar: '#0f1520', accent: '#b0cceb' },
{ id: 'light-coffee', label: 'Light Coffee', bg: '#fdf8f3', sidebar: '#f5ede0', accent: '#8b5e3c' },
{ id: 'dark-coffee', label: 'Dark Coffee', bg: '#1a1008', sidebar: '#211408', accent: '#c8894d' },
{ id: 'cotton-candy', label: 'Cotton Candy', bg: '#fff0f8', sidebar: '#ffe8f4', accent: '#e85fa0' },
{ id: 'crimson', label: 'Crimson', bg: '#1a0808', sidebar: '#220a0a', accent: '#e83535' },
{ id: 'cloud', label: 'Cloud', bg: '#f0f8ff', sidebar: '#e4f2fc', accent: '#2879c8' },
{ id: 'peach', label: 'Peach', bg: '#fff8f4', sidebar: '#ffeee4', accent: '#e8704a' },
];
const accentPresets = [
@@ -411,6 +425,11 @@
{ name: 'Cyan', light: '#0891b2', dark: '#5ba8b5' },
{ name: 'Orange', light: '#ea580c', dark: '#c98560' },
{ name: 'Teal', light: '#0d9488', dark: '#5fada5' },
{ name: 'Sakura', light: '#d4547a', dark: '#f4a0b5' },
{ name: 'Gold', light: '#b8860b', dark: '#fabd2f' },
{ name: 'Lavender', light: '#9d5cdb', dark: '#c084fc' },
{ name: 'Sky', light: '#0284c7', dark: '#38bdf8' },
{ name: 'Coral', light: '#e85d4a', dark: '#ff8a7a' },
];
const fontSizePresets = [
@@ -450,10 +469,19 @@
];
let activeAccent = $state($appConfig?.accent_color ?? 'Indigo');
let customAccentColor = $state(($appConfig?.accent_color?.startsWith('#') ? $appConfig.accent_color : null) ?? '#5b6abf');
let activeFontSize = $state($appConfig?.font_size ?? 14);
let activeFontFamily = $state($appConfig?.font_family ?? 'system');
let activeLineHeight = $state($appConfig?.line_height ?? 1.6);
let activeUiScale = $state($appConfig?.ui_scale ?? 1);
let themeDropdownOpen = $state(false);
let accentDropdownOpen = $state(false);
let activeThemePreset = $derived(themePresets.find(p => p.id === $theme) ?? themePresets[0]);
let activeAccentPreset = $derived(accentPresets.find(p => p.name === activeAccent) ?? accentPresets[0]);
let isCustomAccent = $derived(activeAccent.startsWith('#'));
let activeAccentColor = $derived(
isCustomAccent ? activeAccent : (isThemeDark ? activeAccentPreset.dark : activeAccentPreset.light)
);
// General settings
let compactNotes = $state($appConfig?.compact_notes ?? false);
@@ -461,6 +489,7 @@
let restoreLastSession = $state($appConfig?.restore_last_session ?? false);
let timeFormat = $state($appConfig?.time_format ?? 'relative');
let weekStart = $state($appConfig?.week_start ?? 'monday');
let dailyTitleFormat = $state($appConfig?.daily_title_format ?? 'localized');
let gpuAcceleration = $state($appConfig?.gpu_acceleration ?? true);
let autostart = $state($appConfig?.autostart ?? false);
@@ -519,6 +548,7 @@
$appConfig.restore_last_session = restoreLastSession;
$appConfig.time_format = timeFormat;
$appConfig.week_start = weekStart;
$appConfig.daily_title_format = dailyTitleFormat;
$appConfig.gpu_acceleration = gpuAcceleration;
$appConfig.autostart = autostart;
$appConfig.pdf_preview = pdfPreview;
@@ -531,7 +561,7 @@
$appConfig.close_to_tray = closeToTray;
$appConfig.enable_wiki_links = enableWikiLinks;
}
setGeneralSettings(compactNotes, timeFormat, weekStart, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
setGeneralSettings(compactNotes, timeFormat, weekStart, dailyTitleFormat, gpuAcceleration, autostart, pdfPreview, pdfHeight, titleMode, hideTitleInBody, showLineNumbers, defaultViewMode, showTrayIcon, closeToTray, enableWikiLinks, showNoteDates, restoreLastSession)
.catch((e) => console.error('Failed to save general settings:', e));
}
@@ -564,6 +594,23 @@
setAccentColor(accentName).catch((e) => console.error('Failed to save accent:', e));
}
function applyCustomAccent(hex: string) {
const root = document.documentElement;
const isDark = darkThemes.includes($theme) || ($theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
root.style.setProperty('--accent', hex);
root.style.setProperty('--text-accent', hex);
root.style.setProperty('--accent-hover', hex);
root.style.setProperty('--accent-light', `color-mix(in srgb, ${hex} ${isDark ? '10%' : '8%'}, transparent)`);
}
function selectCustomAccent(hex: string) {
customAccentColor = hex;
activeAccent = hex;
applyCustomAccent(hex);
if ($appConfig) $appConfig.accent_color = hex;
setAccentColor(hex).catch((e) => console.error('Failed to save custom accent:', e));
}
function selectFontSize(size: number) {
activeFontSize = size;
applyFontSize(size);
@@ -612,6 +659,14 @@
}
}
function clickOutside(node: HTMLElement, callback: () => void) {
function handle(e: MouseEvent) {
if (!node.contains(e.target as Node)) callback();
}
document.addEventListener('mousedown', handle, true);
return { destroy() { document.removeEventListener('mousedown', handle, true); } };
}
function close() {
// Ensure AI settings are saved when closing (in case input wasn't blurred)
// Only save if the key differs from what's already stored
@@ -625,10 +680,16 @@
$effect(() => {
const savedAccent = $appConfig?.accent_color;
if (savedAccent) {
const preset = accentPresets.find(p => p.name === savedAccent);
if (preset) {
if (savedAccent.startsWith('#')) {
activeAccent = savedAccent;
applyAccent(preset);
customAccentColor = savedAccent;
applyCustomAccent(savedAccent);
} else {
const preset = accentPresets.find(p => p.name === savedAccent);
if (preset) {
activeAccent = savedAccent;
applyAccent(preset);
}
}
}
const savedSize = $appConfig?.font_size;
@@ -656,6 +717,7 @@
restoreLastSession = $appConfig.restore_last_session ?? false;
timeFormat = $appConfig.time_format ?? 'relative';
weekStart = $appConfig.week_start ?? 'monday';
dailyTitleFormat = $appConfig.daily_title_format ?? 'localized';
gpuAcceleration = $appConfig.gpu_acceleration ?? true;
autostart = $appConfig.autostart ?? false;
pdfPreview = $appConfig.pdf_preview ?? false;
@@ -801,6 +863,17 @@
</div>
</div>
<div class="settings-section">
<h3>Daily note title format</h3>
<div class="setting-options">
<button class="option-btn" class:active={dailyTitleFormat === 'localized'} onclick={() => { dailyTitleFormat = 'localized'; saveGeneralSettings(); }}>Localized</button>
<button class="option-btn" class:active={dailyTitleFormat === 'iso'} onclick={() => { dailyTitleFormat = 'iso'; saveGeneralSettings(); }}>2026-06-10</button>
<button class="option-btn" class:active={dailyTitleFormat === 'long'} onclick={() => { dailyTitleFormat = 'long'; saveGeneralSettings(); }}>June 10, 2026</button>
<button class="option-btn" class:active={dailyTitleFormat === 'us'} onclick={() => { dailyTitleFormat = 'us'; saveGeneralSettings(); }}>06/10/2026</button>
<button class="option-btn" class:active={dailyTitleFormat === 'eu'} onclick={() => { dailyTitleFormat = 'eu'; saveGeneralSettings(); }}>10/06/2026</button>
</div>
</div>
{#if isMobile}
<div class="settings-section">
<h3>Vault</h3>
@@ -956,45 +1029,96 @@
</div>
{:else if activeTab === 'styling'}
<div class="tab-content">
<div class="two-col-sections">
<div class="settings-section">
<h3>Theme</h3>
<div class="theme-grid">
{#each themePresets as preset}
<button
class="theme-swatch"
class:active={$theme === preset.id}
onclick={() => { $theme = preset.id; setTheme(preset.id); }}
title={preset.label}
>
<span class="theme-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
<div class="dropdown-wrap">
<button
class="dropdown-trigger"
class:open={themeDropdownOpen}
onclick={() => { themeDropdownOpen = !themeDropdownOpen; accentDropdownOpen = false; }}
>
<span class="dropdown-preview" style="--preview-bg: {activeThemePreset.bg}; --preview-sidebar: {activeThemePreset.sidebar}; --preview-accent: {activeThemePreset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
<span class="theme-label">{preset.label}</span>
</button>
{/each}
</span>
<span class="dropdown-trigger-label">{activeThemePreset.label}</span>
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if themeDropdownOpen}
<div class="dropdown-list" use:clickOutside={() => themeDropdownOpen = false}>
{#each themePresets as preset}
<button
class="dropdown-item"
class:active={$theme === preset.id}
onclick={() => { $theme = preset.id; setTheme(preset.id); themeDropdownOpen = false; }}
>
<span class="dropdown-preview" style="--preview-bg: {preset.bg}; --preview-sidebar: {preset.sidebar}; --preview-accent: {preset.accent}">
<span class="preview-sidebar"></span>
<span class="preview-main">
<span class="preview-accent-bar"></span>
</span>
</span>
<span class="dropdown-item-label">{preset.label}</span>
{#if $theme === preset.id}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="settings-section">
<h3>Accent Color</h3>
<div class="accent-grid">
{#each accentPresets as preset}
<button
class="accent-swatch"
class:active={activeAccent === preset.name}
style="--swatch-color: {isThemeDark ? preset.dark : preset.light}"
onclick={() => selectAccent(preset)}
title={preset.name}
>
<span class="swatch-circle"></span>
<span class="swatch-label">{preset.name}</span>
</button>
{/each}
<div class="dropdown-wrap">
<button
class="dropdown-trigger"
class:open={accentDropdownOpen}
onclick={() => { accentDropdownOpen = !accentDropdownOpen; themeDropdownOpen = false; }}
>
<span class="accent-dot" style="background: {activeAccentColor}"></span>
<span class="dropdown-trigger-label">{isCustomAccent ? 'Custom' : activeAccentPreset.name}</span>
<svg class="dropdown-chevron" viewBox="0 0 16 16" fill="none"><path d="M4 6l4 4 4-4" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
</button>
{#if accentDropdownOpen}
<div class="dropdown-list" use:clickOutside={() => accentDropdownOpen = false}>
{#each accentPresets as preset}
<button
class="dropdown-item"
class:active={activeAccent === preset.name}
onclick={() => { selectAccent(preset); accentDropdownOpen = false; }}
>
<span class="accent-dot" style="background: {isThemeDark ? preset.dark : preset.light}"></span>
<span class="dropdown-item-label">{preset.name}</span>
{#if activeAccent === preset.name}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</button>
{/each}
<div class="dropdown-divider"></div>
<label class="dropdown-item custom-accent-item" class:active={isCustomAccent}>
<span class="accent-dot" style="background: {customAccentColor}"></span>
<span class="dropdown-item-label">Custom</span>
<input
type="color"
class="color-wheel-input"
value={customAccentColor}
oninput={(e) => applyCustomAccent(e.currentTarget.value)}
onchange={(e) => selectCustomAccent(e.currentTarget.value)}
/>
{#if isCustomAccent}
<svg class="dropdown-check" viewBox="0 0 16 16" fill="none"><path d="M3 8l4 4 6-6" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
{/if}
</label>
</div>
{/if}
</div>
</div>
</div>
<div class="settings-section">
<h3>Font Size</h3>
@@ -1244,7 +1368,7 @@
<button class="option-btn" class:active={aiProvider === 'ollama'} onclick={() => { if (aiProvider === 'ollama') return; aiProvider = 'ollama'; aiModel = 'gemma3:4b'; aiTestMessage = null; saveAiSettings(); }}>Ollama</button>
<button class="option-btn" class:active={aiProvider === 'anthropic'} onclick={() => { if (aiProvider === 'anthropic') return; aiProvider = 'anthropic'; aiModel = 'claude-sonnet-4-6'; aiTestMessage = null; saveAiSettings(); }}>Anthropic</button>
<button class="option-btn" class:active={aiProvider === 'openai'} onclick={() => { if (aiProvider === 'openai') return; aiProvider = 'openai'; aiModel = 'gpt-5.5'; aiTestMessage = null; saveAiSettings(); }}>OpenAI</button>
<button class="option-btn" class:active={aiProvider === 'openai_compatible'} onclick={() => { if (aiProvider === 'openai_compatible') return; aiProvider = 'openai_compatible'; aiModel = ''; aiTestMessage = null; saveAiSettings(); }}>Compatible</button>
<button class="option-btn" class:active={aiProvider === 'openai_compatible'} onclick={() => { if (aiProvider === 'openai_compatible') return; aiProvider = 'openai_compatible'; aiModel = ''; aiTestMessage = null; saveAiSettings(); }}>OpenAI Compatible</button>
</div>
</div>
@@ -1299,7 +1423,7 @@
oninput={(e) => { _openaiCompatibleBaseUrl = (e.target as HTMLInputElement).value; }}
onblur={saveAiSettings}
/>
<p class="setting-hint">Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, etc.). Uses the <code>v1/completions</code> endpoint.</p>
<p class="setting-hint">Base URL for any OpenAI-compatible server (LM Studio, LocalAI, vLLM, Nvidia NIM, etc.). Enter the server root; the app appends <code>/v1/chat/completions</code> (a trailing <code>/v1</code> is fine too).</p>
</div>
<div class="settings-section">
<h3>API Key <span style="font-weight: 400; text-transform: none; font-size: 11px;">(optional)</span></h3>
@@ -1546,6 +1670,12 @@
<p class="update-version">HelixNotes <strong>v{appVersion}</strong></p>
</div>
{#if isManagedInstall($installType)}
<div class="settings-section">
<h3>Updates</h3>
<p class="setting-hint">HelixNotes was installed through your system package manager, which delivers updates. The app does not check for or install updates on its own.</p>
</div>
{:else}
<div class="settings-section">
<h3>Check for Updates</h3>
<button class="import-btn" onclick={handleCheckUpdate} disabled={updateChecking || updateDownloading}>
@@ -1560,6 +1690,7 @@
{/if}
</button>
</div>
{/if}
{#if updateAvailable}
<div class="settings-section">
@@ -1868,42 +1999,116 @@
font-weight: 500;
}
.theme-grid {
.two-col-sections {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 8px;
grid-template-columns: 1fr 1fr;
gap: 0 16px;
}
.theme-swatch {
.two-col-sections .settings-section {
margin-bottom: 24px;
}
.dropdown-wrap {
position: relative;
}
.dropdown-trigger {
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
gap: 5px;
padding: 6px;
gap: 10px;
padding: 8px 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.15s;
transition: border-color 0.15s;
}
.theme-swatch:hover {
border-color: var(--text-tertiary);
}
.theme-swatch.active {
.dropdown-trigger:hover,
.dropdown-trigger.open {
border-color: var(--accent);
box-shadow: 0 0 0 2px var(--accent-light);
}
.theme-preview {
display: flex;
.dropdown-trigger-label {
flex: 1;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.dropdown-chevron {
width: 14px;
height: 14px;
color: var(--text-tertiary);
flex-shrink: 0;
transition: transform 0.15s;
}
.dropdown-trigger.open .dropdown-chevron {
transform: rotate(180deg);
}
.dropdown-list {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
box-shadow: var(--shadow-md);
z-index: 100;
max-height: 240px;
overflow-y: auto;
padding: 4px;
}
.dropdown-item {
width: 100%;
height: 32px;
display: flex;
align-items: center;
gap: 10px;
padding: 7px 8px;
border-radius: 5px;
background: none;
border: none;
cursor: pointer;
transition: background 0.1s;
}
.dropdown-item:hover {
background: var(--bg-hover);
}
.dropdown-item.active {
background: var(--accent-light);
}
.dropdown-item-label {
flex: 1;
font-size: 13px;
color: var(--text-primary);
text-align: left;
}
.dropdown-check {
width: 14px;
height: 14px;
color: var(--accent);
flex-shrink: 0;
}
.dropdown-preview {
display: flex;
width: 36px;
height: 22px;
border-radius: 4px;
overflow: hidden;
background: var(--preview-bg);
border: 1px solid rgba(0,0,0,0.1);
border: 1px solid rgba(0,0,0,0.12);
flex-shrink: 0;
}
.preview-sidebar {
@@ -1915,10 +2120,10 @@
.preview-main {
flex: 1;
background: var(--preview-bg);
padding: 4px;
padding: 3px;
display: flex;
flex-direction: column;
gap: 3px;
gap: 2px;
}
.preview-accent-bar {
@@ -1928,62 +2133,33 @@
border-radius: 2px;
}
.theme-label {
font-size: 10px;
color: var(--text-secondary);
text-align: center;
line-height: 1.2;
word-break: break-word;
}
.theme-swatch.active .theme-label {
color: var(--accent);
}
.accent-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
}
.accent-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 12px 8px;
border: 1px solid var(--border-color);
border-radius: 10px;
background: var(--bg-secondary);
cursor: pointer;
transition: all 0.15s;
}
.accent-swatch:hover {
background: var(--bg-hover);
border-color: var(--swatch-color);
}
.accent-swatch.active {
border-color: var(--swatch-color);
background: var(--accent-light);
}
.swatch-circle {
width: 24px;
height: 24px;
.accent-dot {
width: 18px;
height: 18px;
border-radius: 50%;
background: var(--swatch-color);
flex-shrink: 0;
}
.swatch-label {
font-size: 11px;
color: var(--text-secondary);
.dropdown-divider {
height: 1px;
background: var(--border-light);
margin: 4px 0;
}
.accent-swatch.active .swatch-label {
color: var(--text-primary);
font-weight: 500;
.custom-accent-item {
cursor: pointer;
position: relative;
}
.color-wheel-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
border: none;
padding: 0;
}
.font-size-options {
@@ -2652,15 +2828,6 @@
font-size: 14px;
}
.settings-panel.mobile .accent-grid {
grid-template-columns: repeat(4, 1fr);
gap: 10px;
}
.settings-panel.mobile .accent-swatch {
padding: 14px 8px;
}
.settings-panel.mobile .font-size-options {
gap: 8px;
}
+54 -14
View File
@@ -67,6 +67,22 @@
$collapsedNotebooks = [...$collapsedNotebooks, path];
}
}
// Collapse-all / expand-all: toggles every notebook that has children.
function collectParentPaths(tree: NotebookEntry[]): string[] {
const out: string[] = [];
for (const nb of tree) {
if (nb.children && nb.children.length > 0) {
out.push(nb.path);
out.push(...collectParentPaths(nb.children));
}
}
return out;
}
const allParentPaths = $derived(collectParentPaths($notebooks));
const allCollapsed = $derived(allParentPaths.length > 0 && allParentPaths.every((p) => $collapsedNotebooks.includes(p)));
function toggleAllCollapse() {
$collapsedNotebooks = allCollapsed ? [] : [...allParentPaths];
}
export async function refresh() {
@@ -578,19 +594,32 @@
}}
>
<span class="section-title">Notebooks</span>
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
<div class="section-actions">
{#if allParentPaths.length > 0}
<button class="icon-btn-sm" onclick={(e) => { e.stopPropagation(); toggleAllCollapse(); }} title={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'} aria-label={allCollapsed ? 'Expand all notebooks' : 'Collapse all notebooks'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
{#if allCollapsed}
<path d="m7 15 5 5 5-5" /><path d="m7 9 5-5 5 5" />
{:else}
<path d="m7 20 5-5 5 5" /><path d="m7 4 5 5 5-5" />
{/if}
</svg>
</button>
{/if}
<button class="icon-btn-sm" onclick={() => {
if (showNewNotebook) {
showNewNotebook = false; newNotebookName = ''; newNotebookParent = null;
} else {
// Unfiled Notes is the root pseudo-notebook (empty relative_path); treat it as root.
newNotebookParent = ($viewMode === 'notebook' && $activeNotebook?.relative_path) ? $activeNotebook : null;
showNewNotebook = true;
}
}} title={$viewMode === 'notebook' && $activeNotebook?.relative_path ? `New notebook inside ${$activeNotebook.name}` : 'New notebook'}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" />
</svg>
</button>
</div>
</div>
{#if showNewNotebook}
@@ -814,8 +843,14 @@
ondragleave={() => { if (dropTargetPath === nb.path) { dropTargetPath = null; dropPosition = null; } }}
ondrop={(e) => {
e.preventDefault();
const pos = dropPosition;
if (draggedNotebookPath) {
// Recompute the drop position from the drop coordinates, not the dragover-set
// state: dragleave over the row's child nodes (chevron/icon/label) clears
// dropPosition before drop (esp. Windows WebView2), which mis-routed every drop
// to "nest" and broke reorder + un-nest. (issue #97)
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const relY = (e.clientY - rect.top) / rect.height;
const pos = relY < 0.3 ? 'above' : relY > 0.7 ? 'below' : 'into';
if (pos === 'above' || pos === 'below') {
handleNotebookReorder(draggedNotebookPath, nb.path, pos);
} else {
@@ -946,6 +981,11 @@
color: var(--text-tertiary);
}
.section-actions {
display: flex;
align-items: center;
gap: 2px;
}
.icon-btn, .icon-btn-sm {
background: none;
border: none;
+31 -11
View File
@@ -3,7 +3,7 @@
import { listen } from '@tauri-apps/api/event';
import { getTasks } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import { tasksLayout, appConfig } from '$lib/stores/app';
import { tasksLayout, tasksHideCompleted, tasksOnlyFlagged, tasksSort, appConfig } from '$lib/stores/app';
import { isAndroid } from '$lib/platform';
import type { TaskItem, FileEvent } from '$lib/types';
@@ -16,8 +16,7 @@
let tasks = $state<TaskItem[]>([]);
let loading = $state(true);
let hideCompleted = $state(true);
let sortBy = $state<'due' | 'priority' | 'note'>('due');
let taskQuery = $state('');
let unlisten: (() => void) | null = null;
async function load() {
@@ -43,16 +42,24 @@
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
// Free-text task filter: matches the task text or its note title (case-insensitive).
function matchesQuery(t: TaskItem): boolean {
const q = taskQuery.trim().toLowerCase();
if (!q) return true;
return t.text.toLowerCase().includes(q) || t.note_title.toLowerCase().includes(q);
}
const filtered = $derived.by(() => {
let list = tasks.slice();
if (hideCompleted) list = list.filter((t) => !t.completed);
if ($tasksHideCompleted) list = list.filter((t) => !t.completed);
if ($tasksOnlyFlagged) list = list.filter((t) => !!t.due || !!t.priority);
list = list.filter(matchesQuery);
list.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
if (sortBy === 'priority') {
if ($tasksSort === 'priority') {
const d = prank(a.priority) - prank(b.priority);
if (d) return d;
} else if (sortBy === 'note') {
} else if ($tasksSort === 'note') {
const d = a.note_title.localeCompare(b.note_title);
if (d) return d;
} else {
@@ -110,7 +117,7 @@
const calDayNames = $derived([...DOW.slice(weekStartsOn), ...DOW.slice(0, weekStartsOn)]);
// Calendar tasks share the list's hide-completed filter (no sort).
const calBase = $derived(tasks.filter((t) => !hideCompleted || !t.completed));
const calBase = $derived(tasks.filter((t) => (!$tasksHideCompleted || !t.completed) && (!$tasksOnlyFlagged || !!t.due || !!t.priority) && matchesQuery(t)));
const tasksByDate = $derived.by(() => {
const m = new Map<string, TaskItem[]>();
@@ -186,7 +193,9 @@
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
{/if}
<div class="tasks-controls">
<button class="tasks-ctl" class:active={hideCompleted} onclick={() => (hideCompleted = !hideCompleted)} title="Hide completed tasks">Hide done</button>
<input class="tasks-filter" type="text" placeholder="Filter tasks…" bind:value={taskQuery} onkeydown={(e) => { if (e.key === 'Escape') taskQuery = ''; }} aria-label="Filter tasks by text" />
<button class="tasks-ctl" class:active={$tasksHideCompleted} onclick={() => ($tasksHideCompleted = !$tasksHideCompleted)} title="Hide completed tasks">Hide done</button>
<button class="tasks-ctl" class:active={$tasksOnlyFlagged} onclick={() => ($tasksOnlyFlagged = !$tasksOnlyFlagged)} title="Only show tasks with a due date or priority">Flagged</button>
{#if !isAndroid}
<div class="tasks-layout">
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => ($tasksLayout = 'list')} title="List view">List</button>
@@ -196,7 +205,7 @@
{#if viewLayout === 'list'}
<div class="tasks-sort">
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
<button class="tasks-ctl" class:active={sortBy === v} onclick={() => (sortBy = v as 'due' | 'priority' | 'note')}>{label}</button>
<button class="tasks-ctl" class:active={$tasksSort === v} onclick={() => ($tasksSort = v as 'due' | 'priority' | 'note')}>{label}</button>
{/each}
</div>
{/if}
@@ -366,6 +375,17 @@
color: #fff;
border-color: var(--accent);
}
.tasks-filter {
width: 150px;
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 12px;
}
.tasks-filter::placeholder { color: var(--text-tertiary); }
.tasks-filter:focus { outline: none; border-color: var(--accent); }
.tasks-list {
flex: 1;
overflow-y: auto;
@@ -458,7 +478,7 @@
background: transparent;
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
opacity: 0;
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-prio.prio-none { opacity: 1; }
@@ -529,7 +549,7 @@
background: transparent;
border-style: dashed;
color: var(--text-tertiary);
opacity: 0;
opacity: 0.4;
transition: opacity 0.1s;
}
.task-row:hover .task-due:not(.set) { opacity: 1; }
+64 -5
View File
@@ -2,7 +2,7 @@
import { open } from '@tauri-apps/plugin-dialog';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { documentDir } from '@tauri-apps/api/path';
import { openVault, getAppConfig } from '$lib/api';
import { openVault, removeVault, getAppConfig } from '$lib/api';
import { appConfig, vaultReady } from '$lib/stores/app';
import { isAndroid, isIOS, isMobile } from '$lib/platform';
import type { VaultConfig } from '$lib/types';
@@ -107,6 +107,15 @@
loading = false;
}
}
async function forgetVault(path: string) {
try {
await removeVault(path);
$appConfig = await getAppConfig();
} catch (e) {
error = String(e);
}
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
@@ -205,10 +214,18 @@
<div class="recent">
<span class="recent-label">Recent</span>
{#each recentVaults as vault}
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}</span>
<span class="vault-path">{vault.path}</span>
</button>
<div class="vault-row">
<button class="vault-item" onclick={() => openSelectedVault(vault.path)}>
<span class="vault-name">{vault.name}</span>
<span class="vault-path">{vault.path}</span>
</button>
<button class="vault-remove" title="Remove from list" aria-label="Remove from list" onclick={() => forgetVault(vault.path)}>
<svg width="12" height="12" viewBox="0 0 10 10">
<line x1="1.5" y1="1.5" x2="8.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
<line x1="8.5" y1="1.5" x2="1.5" y2="8.5" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" />
</svg>
</button>
</div>
{/each}
</div>
{/if}
@@ -389,6 +406,48 @@
white-space: nowrap;
}
.vault-row {
display: flex;
align-items: stretch;
gap: 6px;
margin-bottom: 6px;
}
.vault-row .vault-item {
flex: 1;
min-width: 0;
margin-bottom: 0;
}
.vault-remove {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 36px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-tertiary);
cursor: pointer;
opacity: 0;
transition: opacity 0.15s, background 0.15s, color 0.15s;
}
.vault-row:hover .vault-remove,
.vault-remove:focus-visible {
opacity: 1;
}
.vault-remove:hover {
background: var(--bg-hover);
color: var(--text-primary);
}
.vault-picker.mobile .vault-remove {
opacity: 1;
}
.vault-name-input {
margin-bottom: 20px;
text-align: left;
@@ -0,0 +1,30 @@
import { Extension } from '@tiptap/core';
import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state';
import { getSelectionPair } from '../selectionPairs';
// TipTap/ProseMirror behavior for wrapping a non-empty rich-editor selection
// without flattening selected inline marks or content to plain text.
export const WrapSelectedText = Extension.create({
name: 'wrapSelectedText',
addProseMirrorPlugins() {
return [
new Plugin({
key: new PluginKey('wrapSelectedText'),
props: {
// Intercept only supported single-character text input over a range.
// Everything else falls back to ProseMirror's normal input handling.
handleTextInput(view, from, to, text) {
const close = getSelectionPair(text);
if (!close || from === to) return false;
const tr = view.state.tr.insertText(close, to).insertText(text, from);
tr.setSelection(TextSelection.create(tr.doc, from + text.length, to + text.length));
view.dispatch(tr);
return true;
},
},
}),
];
},
});
+15
View File
@@ -0,0 +1,15 @@
// Shared source of truth for editor behaviors that wrap an active selection.
// Keep this small: only characters users reasonably expect to form pairs.
const selectionPairs: Record<string, string> = {
'(': ')',
'{': '}',
'[': ']',
"'": "'",
'"': '"',
'`': '`',
};
// Returns the closing character for a typed wrapper key, or null to pass through.
export function getSelectionPair(key: string) {
return selectionPairs[key] ?? null;
}
+14
View File
@@ -0,0 +1,14 @@
import { getSelectionPair } from '../selectionPairs';
// Pure source-mode helper: computes the wrapped textarea value and preserved
// inner selection so Svelte event handlers stay as glue code.
export function wrapTextareaSelection(value: string, start: number, end: number, key: string) {
const close = getSelectionPair(key);
if (!close || start === end) return null;
return {
value: value.slice(0, start) + key + value.slice(start, end) + close + value.slice(end),
selectionStart: start + key.length,
selectionEnd: end + key.length,
};
}
+17
View File
@@ -17,6 +17,9 @@ export const vaultReady = writable(false);
export const viewMode = writable<ViewMode>("all");
export const sortMode = writable<SortMode>("modified");
export const tasksLayout = writable<"list" | "calendar">("list");
export const tasksHideCompleted = writable<boolean>(true);
export const tasksOnlyFlagged = writable<boolean>(false);
export const tasksSort = writable<"due" | "priority" | "note">("due");
export const sidebarCollapsed = writable(false);
export const sidebarWidth = writable(220);
export const notelistWidth = writable(280);
@@ -82,8 +85,22 @@ export const updateAvailable = writable<{
} | null>(null);
export const updateObj = writable<any>(null);
export const installType = writable<string>("native");
// True only on the Android/iOS build. Defaults to a user-agent guess for the first paint, then
// gets the authoritative compile-time value from the backend at startup (see +layout). (#63)
export const platformIsMobile = writable<boolean>(
typeof navigator !== "undefined" && /android|iphone|ipad|ipod/i.test(navigator.userAgent),
);
export const androidApkUrl = writable<string | null>(null);
// Install types that handle their own updates (in-app auto-updater, or a
// package-manager notice for deb/aur). Anything else - e.g. a distro repo build
// that sets the HELIXNOTES_INSTALL_TYPE build flag (Solus, etc.) - is "managed":
// the app does no update check and shows no update UI at all.
const SELF_UPDATING_INSTALL_TYPES = ["macos", "windows", "deb", "aur", "appimage", "native", "android"];
export function isManagedInstall(type: string): boolean {
return !SELF_UPDATING_INSTALL_TYPES.includes(type);
}
export async function checkForUpdate() {
try {
const { check } = await import("@tauri-apps/plugin-updater");
+4
View File
@@ -59,6 +59,7 @@ export interface AppConfig {
show_note_dates: boolean;
time_format: string;
week_start: string;
daily_title_format: string;
gpu_acceleration: boolean;
autostart: boolean;
pdf_preview: boolean;
@@ -110,6 +111,9 @@ export interface VaultState {
last_notebook?: string | null;
last_tag?: string | null;
tasks_layout?: string;
tasks_hide_completed?: boolean;
tasks_only_flagged?: boolean;
tasks_sort?: string;
}
export interface SearchResult {
+150
View File
@@ -0,0 +1,150 @@
const SECRET_VERSION = 2;
const SECRET_CIPHER = 'AES-256-GCM';
const SECRET_KDF = 'PBKDF2-HMAC-SHA256';
const SECRET_ITERATIONS = 600_000;
const SALT_BYTES = 16;
const NONCE_BYTES = 12;
interface SecretEnvelope {
v: number;
title?: string;
cipher: string;
kdf: string;
iterations: number;
salt: string;
nonce: string;
ct: string;
}
const encoder = new TextEncoder();
const decoder = new TextDecoder();
function bytesToBase64(bytes: Uint8Array): string {
let binary = '';
for (const byte of bytes) binary += String.fromCharCode(byte);
return btoa(binary);
}
function base64ToBytes(value: string, field: string): Uint8Array {
const text = value.trim();
if (!text || text.length % 4 !== 0 || !/^[A-Za-z0-9+/]+={0,2}$/.test(text)) {
throw new Error(`Invalid secret ${field}`);
}
const binary = atob(text);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
return bytes;
}
function stableBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
const copy = new Uint8Array(bytes.byteLength);
copy.set(bytes);
return copy;
}
function authenticatedMetadata(envelope: SecretEnvelope): string {
return JSON.stringify({
v: envelope.v,
title: envelope.title ?? 'Encrypted secret',
cipher: envelope.cipher,
kdf: envelope.kdf,
iterations: envelope.iterations,
salt: envelope.salt,
nonce: envelope.nonce,
});
}
function envelopeAad(envelope: SecretEnvelope): Uint8Array {
return encoder.encode(authenticatedMetadata(envelope));
}
function parseEnvelope(payload: string): SecretEnvelope {
let envelope: SecretEnvelope;
try {
envelope = JSON.parse(payload);
} catch {
throw new Error('Invalid secret payload');
}
if (
envelope?.v !== SECRET_VERSION ||
envelope.cipher !== SECRET_CIPHER ||
envelope.kdf !== SECRET_KDF ||
!Number.isInteger(envelope.iterations) ||
envelope.iterations < 100_000 ||
envelope.iterations > 5_000_000
) {
throw new Error('Unsupported secret payload');
}
return envelope;
}
export function readSecretTitle(payload: string): string {
try {
const parsed = JSON.parse(payload);
return typeof parsed?.title === 'string' && parsed.title.trim()
? parsed.title.trim()
: 'Encrypted secret';
} catch {
return 'Encrypted secret';
}
}
async function deriveKey(passphrase: string, salt: Uint8Array, iterations: number): Promise<CryptoKey> {
if (!passphrase) throw new Error('Passphrase is required');
const keyMaterial = await crypto.subtle.importKey(
'raw',
encoder.encode(passphrase),
'PBKDF2',
false,
['deriveKey'],
);
return crypto.subtle.deriveKey(
{ name: 'PBKDF2', hash: 'SHA-256', salt: stableBytes(salt), iterations },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt'],
);
}
export async function encryptSecretText(plaintext: string, passphrase: string, title = 'Encrypted secret'): Promise<string> {
if (!plaintext) throw new Error('Secret text is required');
const salt = crypto.getRandomValues(new Uint8Array(SALT_BYTES));
const nonce = crypto.getRandomValues(new Uint8Array(NONCE_BYTES));
const key = await deriveKey(passphrase, salt, SECRET_ITERATIONS);
const envelope: SecretEnvelope = {
v: SECRET_VERSION,
title: title.trim() || 'Encrypted secret',
cipher: SECRET_CIPHER,
kdf: SECRET_KDF,
iterations: SECRET_ITERATIONS,
salt: bytesToBase64(salt),
nonce: bytesToBase64(nonce),
ct: '',
};
const encrypted = await crypto.subtle.encrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
encoder.encode(plaintext),
);
envelope.ct = bytesToBase64(new Uint8Array(encrypted));
return JSON.stringify(envelope, null, 2);
}
export async function decryptSecretText(payload: string, passphrase: string): Promise<string> {
try {
const envelope = parseEnvelope(payload);
const salt = base64ToBytes(envelope.salt, 'salt');
const nonce = base64ToBytes(envelope.nonce, 'nonce');
const ciphertext = base64ToBytes(envelope.ct, 'ciphertext');
const key = await deriveKey(passphrase, salt, envelope.iterations);
const plaintext = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: stableBytes(nonce), additionalData: stableBytes(envelopeAad(envelope)) },
key,
stableBytes(ciphertext),
);
return decoder.decode(plaintext);
} catch {
throw new Error('Unable to unlock secret. Check the passphrase or payload.');
}
}
+11 -7
View File
@@ -1,8 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import '../app.css';
import { theme, appConfig, activeNote, activeNotePath, installType, checkForUpdate, checkForUpdateMobile } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType } from '$lib/api';
import { theme, appConfig, activeNote, activeNotePath, installType, platformIsMobile, checkForUpdate, checkForUpdateMobile, isManagedInstall } from '$lib/stores/app';
import { openFile, openUrl, readNote, getInstallType, isMobilePlatform } from '$lib/api';
import { get } from 'svelte/store';
import ResizeHandles from '$lib/components/ResizeHandles.svelte';
@@ -16,8 +16,8 @@
});
function applyTheme(t: string) {
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
const root = document.documentElement;
root.classList.remove('dark');
root.removeAttribute('data-theme');
@@ -53,7 +53,7 @@
}
function resolveAndHandleLink(href: string) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
if (href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:') || href.startsWith('sms:')) {
openUrl(href).catch((err) => console.error('Failed to open URL:', err));
} else if (!href.startsWith('#')) {
const decoded = decodeURIComponent(href);
@@ -88,12 +88,16 @@
// Detect install type and check for updates on startup
onMount(() => {
// Authoritative platform from the backend (compile-time), overriding the UA guess. (#63)
isMobilePlatform().then((m) => platformIsMobile.set(m)).catch(() => {});
if (isMobile) {
installType.set('android');
checkForUpdateMobile();
} else {
getInstallType().then(t => installType.set(t)).catch(() => {});
checkForUpdate();
getInstallType().then(t => {
installType.set(t);
if (!isManagedInstall(t)) checkForUpdate();
}).catch(() => {});
}
});
+129 -19
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onDestroy, onMount } from 'svelte';
import { appConfig, vaultReady, theme } from '$lib/stores/app';
import { getAppConfig, openVault } from '$lib/api';
import { getAppConfig, openVault, setFontSize } from '$lib/api';
import { getCurrentWebview } from '@tauri-apps/api/webview';
import VaultPicker from '$lib/components/VaultPicker.svelte';
import AppLayout from '$lib/components/AppLayout.svelte';
@@ -9,6 +9,100 @@
let loading = $state(true);
let noteWindowPath = $state<string | null>(null);
let fontSizeSaveTimer: ReturnType<typeof setTimeout> | null = null;
let removeEditorZoomShortcuts: (() => void) | null = null;
const defaultEditorFontSize = 14;
const minEditorFontSize = 10;
const maxEditorFontSize = 32;
function getEditorScrollSurface(): HTMLElement | HTMLTextAreaElement | null {
const sourceEditor = document.querySelector<HTMLTextAreaElement>('.source-editor');
if (sourceEditor && getComputedStyle(sourceEditor).display !== 'none') return sourceEditor;
return document.querySelector<HTMLElement>('.editor-body');
}
function preserveEditorViewportAfterLayout(scrollSurface: HTMLElement | HTMLTextAreaElement | null) {
if (!scrollSurface) return () => {};
const isSourceEditor = scrollSurface instanceof HTMLTextAreaElement;
const maxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
const scrollRatio = maxScroll > 0 ? scrollSurface.scrollTop / maxScroll : 0;
const rect = scrollSurface.getBoundingClientRect();
const anchorY = Math.min(rect.bottom - 1, rect.top + Math.max(16, rect.height * 0.25));
let anchorElement: HTMLElement | null = null;
let anchorOffset = 0;
if (!isSourceEditor) {
const candidate = document.elementFromPoint(rect.left + Math.min(80, rect.width / 2), anchorY);
anchorElement = candidate?.closest('.tiptap *') as HTMLElement | null;
if (anchorElement) anchorOffset = anchorElement.getBoundingClientRect().top - rect.top;
}
return () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (anchorElement && scrollSurface.contains(anchorElement)) {
const nextRect = scrollSurface.getBoundingClientRect();
scrollSurface.scrollTop += anchorElement.getBoundingClientRect().top - nextRect.top - anchorOffset;
} else {
const nextMaxScroll = scrollSurface.scrollHeight - scrollSurface.clientHeight;
scrollSurface.scrollTop = Math.max(0, nextMaxScroll * scrollRatio);
}
});
});
};
}
function setEditorFontSize(value: number) {
const nextSize = Math.max(minEditorFontSize, Math.min(maxEditorFontSize, Math.round(value)));
const restoreEditorViewport = preserveEditorViewportAfterLayout(getEditorScrollSurface());
if ($appConfig) $appConfig.font_size = nextSize;
document.documentElement.style.setProperty('--editor-font-size', `${nextSize}px`);
restoreEditorViewport();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
fontSizeSaveTimer = setTimeout(() => {
setFontSize(nextSize).catch((e) => console.error('Failed to save font size:', e));
fontSizeSaveTimer = null;
}, 150);
}
function installEditorZoomShortcuts() {
const zoomEditor = (delta: number) => setEditorFontSize(($appConfig?.font_size ?? defaultEditorFontSize) + delta);
const handleKeydown = (event: KeyboardEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
if (event.altKey || event.shiftKey) return;
if (event.key === '+' || event.key === '=') {
event.preventDefault();
event.stopPropagation();
zoomEditor(1);
} else if (event.key === '-' || event.key === '_') {
event.preventDefault();
event.stopPropagation();
zoomEditor(-1);
} else if (event.key === '0') {
event.preventDefault();
event.stopPropagation();
setEditorFontSize(defaultEditorFontSize);
}
};
const handleWheel = (event: WheelEvent) => {
if (!event.ctrlKey && !event.metaKey) return;
event.preventDefault();
event.stopPropagation();
zoomEditor(event.deltaY < 0 ? 1 : -1);
};
window.addEventListener('keydown', handleKeydown);
window.addEventListener('wheel', handleWheel, { passive: false, capture: true });
return () => {
window.removeEventListener('keydown', handleKeydown);
window.removeEventListener('wheel', handleWheel, { capture: true });
};
}
onMount(async () => {
// Check for note window mode
@@ -26,8 +120,8 @@
// Apply theme immediately to prevent flash
const themeValue = config.theme || 'system';
const root = document.documentElement;
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula'];
const darkThemes = ['dark', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'dark-coffee', 'crimson'];
const namedThemes = ['solarized-light', 'solarized-dark', 'catppuccin', 'nord', 'tokyo-night', 'github-light', 'github-dark', 'dracula', 'blueberry', 'forest-green', 'gruvbox', 'midnight-tide', 'cherry-blossom', 'synthwave', 'ember', 'moonlit', 'light-coffee', 'dark-coffee', 'cotton-candy', 'crimson', 'cloud', 'peach'];
root.classList.remove('dark');
root.removeAttribute('data-theme');
if (namedThemes.includes(themeValue)) {
@@ -60,21 +154,31 @@
}
// Apply saved accent
if (config.accent_color) {
const accentPresets: Record<string, { light: string; dark: string }> = {
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
Rose: { light: '#e11d48', dark: '#d4768a' },
Emerald: { light: '#059669', dark: '#5bab8a' },
Amber: { light: '#d97706', dark: '#c9a04e' },
Purple: { light: '#7c3aed', dark: '#9a82c4' },
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
Orange: { light: '#ea580c', dark: '#c98560' },
Teal: { light: '#0d9488', dark: '#5fada5' },
};
const preset = accentPresets[config.accent_color];
if (preset) {
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
const color = isDark ? preset.dark : preset.light;
const themeVal = config.theme || 'light';
const isDark = darkThemes.includes(themeVal) || (themeVal === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches);
let color: string | null = null;
if (config.accent_color.startsWith('#')) {
color = config.accent_color;
} else {
const accentPresets: Record<string, { light: string; dark: string }> = {
Indigo: { light: '#5b6abf', dark: '#7b9bd4' },
Rose: { light: '#e11d48', dark: '#d4768a' },
Emerald: { light: '#059669', dark: '#5bab8a' },
Amber: { light: '#d97706', dark: '#c9a04e' },
Purple: { light: '#7c3aed', dark: '#9a82c4' },
Cyan: { light: '#0891b2', dark: '#5ba8b5' },
Orange: { light: '#ea580c', dark: '#c98560' },
Teal: { light: '#0d9488', dark: '#5fada5' },
Sakura: { light: '#d4547a', dark: '#f4a0b5' },
Gold: { light: '#b8860b', dark: '#fabd2f' },
Lavender: { light: '#9d5cdb', dark: '#c084fc' },
Sky: { light: '#0284c7', dark: '#38bdf8' },
Coral: { light: '#e85d4a', dark: '#ff8a7a' },
};
const preset = accentPresets[config.accent_color];
if (preset) color = isDark ? preset.dark : preset.light;
}
if (color) {
const root = document.documentElement;
root.style.setProperty('--accent', color);
root.style.setProperty('--text-accent', color);
@@ -93,6 +197,7 @@
console.error('Failed to apply interface scale:', e);
}
}
if (!noteWindowPath) removeEditorZoomShortcuts = installEditorZoomShortcuts();
// Auto-open last vault if available
if (config.active_vault) {
@@ -111,6 +216,11 @@
}
loading = false;
});
onDestroy(() => {
removeEditorZoomShortcuts?.();
if (fontSizeSaveTimer) clearTimeout(fontSizeSaveTimer);
});
</script>
{#if loading}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata) Literata-Italic[opsz,wght].ttf: Copyright 2017 The Literata Project Authors (https://github.com/googlefonts/literata)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora". Lora-Italic[wght].ttf: Copyright 2011 The Lora Project Authors (https://github.com/cyrealtype/Lora-Cyrillic), with Reserved Font Name "Lora".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather". Merriweather-Italic[opsz,wdth,wght].ttf: Copyright 2024 The Merriweather Project Authors (https://github.com/EbenSorkin/Merriweather4) with Reserved Font Name "Merriweather".
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans) OpenSans-Italic[wdth,wght].ttf: Copyright 2020 The Open Sans Project Authors (https://github.com/googlefonts/opensans)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.