Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions AUDIT_LOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,19 @@

This log tracks all significant changes, updates, and versions in the PaperCache project.

## 2026-07-01 (v0.5.9 Release: Image Support & UI Consistency)
**Change:** feat(release): bump version to 0.5.9; implement image paste support and markdown image widget; align background blur and font typography across modals and timers; extract audio recording features to external project

**Details/Why:**
1. **Version Bump**: Bumped application version to 0.5.9 across `package.json`, `src-tauri/tauri.conf.json`, and `src-tauri/Cargo.toml`. Added release note `notes/New Features in v0.5.9.md`.
2. **Image Support**: Implemented image paste handler in `src/lib/editor/extensions.ts` which captures pasted images, saves them to `.images` directory via Tauri `save_asset` IPC command, and inserts markdown `![image](path)`. Rendered inline via `ImageWidget` in CodeMirror (`src/lib/editor/widgets.ts`, `markdownPlugin.ts`).
3. **UI Consistency**: Standardized background blur styling across search modal and timers menu (`TimersPage.tsx`, `KeybindsModal.tsx`, `App.css`). Ensured timers menu adheres to custom app typography.
4. **Product Scope Separation**: Extracted voice memo audio recording and floating indicator features into a dedicated standalone repository at `~/Projects/Memo` per user instruction.

**Files changed:** `package.json`, `src-tauri/tauri.conf.json`, `src-tauri/Cargo.toml`, `src/api.ts`, `src/types.d.ts`, `src/App.css`, `src/components/KeybindsModal.tsx`, `src/components/TimersPage.tsx`, `src/lib/editor/extensions.ts`, `src/lib/editor/markdownPlugin.ts`, `src/lib/editor/widgets.ts`, `src/lib/editor/widgets.test.ts`, `src-tauri/src/lib.rs`, `src-tauri/src/commands/fs.rs`, `notes/New Features in v0.5.9.md` [NEW], `CHANGELOG.md`, `AUDIT_LOG.md`.

---

## 2026-06-30 (Linux Runner Pinning for `glibc` Compatibility Fix)
**Change:** fix(ci): pin Linux workflow runner to `ubuntu-22.04` across CI and release workflows to ensure `glibc 2.35` compatibility

Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]


## [v0.5.9] - 2026-07-01

### Added
- **Image Copy & Paste Support**: You can now copy any image and paste it directly into your notes. Pasted images are automatically saved locally in a hidden `.images` folder and rendered seamlessly inline as image cards.

### Changed
- **Consistent Glassmorphic Backgrounds**: Standardized background blur effects across modal overlays (Command+P Search and Command+T Timers menu) to ensure consistent visual aesthetics.
- **Dynamic Typography in Timers**: Ensure the Timers menu respects custom font family selections made in App Settings.

### Fixed
- **Linux Compatibility (`glibc` version mismatch)**: Pinned GitHub Actions Linux runner to `ubuntu-22.04` across CI and release workflows instead of `ubuntu-latest` (Ubuntu 24.04). This ensures built Linux binaries and AppImages link against `glibc 2.35` so they can run out-of-the-box on Ubuntu 22.04 LTS, Debian 12, and other distributions without throwing `version glibc 2.38 not found` errors.

Expand Down
11 changes: 11 additions & 0 deletions notes/New Features in v0.5.9.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# New Features in v0.5.9

Welcome to PaperCache v0.5.9!

Here are the new features and improvements implemented in this release:
- **Image Support**: You can now copy and paste images directly into your notes! Images are automatically saved locally in a hidden `.images` folder and rendered seamlessly inside the editor as beautiful image cards.
- **Consistent Visual Design**: Aligned background blur styling across modal overlays (Command+P search and Command+T Timers menu) for a cohesive glassmorphic aesthetic across the app.
- **Unified Typography**: Ensure the Timers menu dynamically respects and follows your chosen custom typography settings from the App Settings.
- **Linux CI Workflow Fix**: Pinned Linux GitHub Action runner to `ubuntu-22.04` for dependable `glibc 2.35` build compatibility.

*(If you have read this note, feel free to delete it)*
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "papercache",
"private": true,
"version": "0.5.8",
"version": "0.5.9",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "papercache"
version = "0.5.8"
version = "0.5.9"
description = "A PaperCache Tauri App"
authors = ["Aditya Sharma"]
edition = "2021"
Expand Down
119 changes: 119 additions & 0 deletions src-tauri/src/commands/fs.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
Expand Down Expand Up @@ -60,6 +61,10 @@ fn walk_dir(dir: &Path, notes: &mut Vec<Note>, base_path: &Path) -> Result<(), S
let entry = entry.map_err(|e| e.to_string())?;
let path = entry.path();
if path.is_dir() {
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
if dir_name == ".images" || dir_name == ".audio" {
continue;
}
walk_dir(&path, notes, base_path)?;
} else if path.is_file() {
let file_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
Expand Down Expand Up @@ -496,3 +501,117 @@ pub fn run_onboarding(app: &AppHandle) {
}
}
}

#[tauri::command(async)]
pub async fn save_asset(data_base64: String, ext: String, folder: String) -> Result<String, String> {
if folder.contains("..") || folder.contains('/') || folder.contains('\\') {
return Err("Invalid folder name".to_string());
}
let folder_name = if folder.starts_with('.') {
folder.clone()
} else {
format!(".{}", folder)
};
if folder_name != ".images" && folder_name != ".audio" {
return Err("Unsupported asset folder".to_string());
}

let base = get_papercache_dir()?;
let asset_dir = base.join(&folder_name);
if !asset_dir.exists() {
tokio::fs::create_dir_all(&asset_dir).await.map_err(|e| e.to_string())?;
}

let clean_ext: String = ext
.trim_start_matches('.')
.chars()
.filter(|c| c.is_alphanumeric())
.collect();
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| e.to_string())?
.as_millis();
let prefix = folder_name.trim_start_matches('.');

// Generate unique filename with random suffix to avoid collisions
use rand::Rng;
let mut rng = rand::thread_rng();
let random_suffix: u32 = rng.gen();
let filename = format!("{}_{}_{:08x}.{}", prefix, timestamp, random_suffix, clean_ext);
let file_path = asset_dir.join(&filename);

let b64_str = if let Some(idx) = data_base64.find(',') {
&data_base64[idx + 1..]
} else {
&data_base64
};

let decoded = BASE64.decode(b64_str).map_err(|e| format!("Failed to decode base64: {}", e))?;
tokio::fs::write(&file_path, &decoded).await.map_err(|e| e.to_string())?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Ok(format!("/{}/{}", folder_name, filename))
}

#[tauri::command(async)]
pub async fn read_asset(path: String) -> Result<String, String> {
let clean_path = path.trim_start_matches('/');
if clean_path.contains("..") {
return Err("Invalid asset path".to_string());
}

// Read-only validation: ensure path is within allowed asset folders
let path_parts: Vec<&str> = clean_path.split('/').collect();
if path_parts.is_empty() {
return Err("Invalid asset path".to_string());
}
let first_component = path_parts[0];
if first_component != ".images" && first_component != ".audio" {
return Err("Asset path must start with .images or .audio".to_string());
}

let base = get_papercache_dir()?;
let mut target = base.clone();
for comp in clean_path.split('/') {
if !comp.is_empty() && comp != "." && comp != ".." {
target.push(comp);
} else if comp == ".." {
return Err("Path traversal detected".to_string());
}
}

// Verify the resolved path is within base without creating any directories
let canonical_base = base.canonicalize().map_err(|e| e.to_string())?;
if !target.exists() {
return Err("Asset file not found".to_string());
}
let canonical_target = target.canonicalize().map_err(|e| e.to_string())?;
if !canonical_target.starts_with(&canonical_base) {
return Err("Path traversal detected".to_string());
}

let file_path = canonical_target;
let bytes = tokio::fs::read(&file_path).await.map_err(|e| e.to_string())?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.

let ext = file_path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
let mime = match ext.as_str() {
"png" => "image/png",
"jpg" | "jpeg" => "image/jpeg",
"gif" => "image/gif",
"webp" => "image/webp",
"svg" => "image/svg+xml",
"webm" => "audio/webm",
"m4a" | "mp4" => "audio/mp4",
"aac" => "audio/aac",
"wav" => "audio/wav",
"mp3" => "audio/mpeg",
"ogg" => "audio/ogg",
_ => "application/octet-stream",
};

let encoded = BASE64.encode(&bytes);
Ok(format!("data:{};base64,{}", mime, encoded))
}
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,8 @@ pub fn run() {
commands::fs::export_note,
commands::fs::set_dialog_open,
commands::fs::remove_onboarding_files,
commands::fs::save_asset,
commands::fs::read_asset,
commands::system::close_window,
commands::system::restore_window_state,
commands::system::quit_app,
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/tauri.conf.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/2.0.0/tauri.schema.json",
"productName": "PaperCache",
"version": "0.5.8",
"version": "0.5.9",
"identifier": "com.variablethe.papercache",
"build": {
"beforeDevCommand": "npm run dev",
Expand Down
5 changes: 4 additions & 1 deletion src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,9 @@ body {
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.4);
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9999;
display: flex;
justify-content: center;
Expand Down Expand Up @@ -910,6 +912,7 @@ body {
inset: 0;
background: rgba(0, 0, 0, 0.35);
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
z-index: 9000;
display: flex;
align-items: center;
Expand Down
2 changes: 2 additions & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,6 @@ export const tauriApi: ElectronAPI = {
onUpdateReady: (callback) => onEvent('update-ready', callback),
restartApp: () => invoke('restart_app'),
onUpdateStatus: (callback) => onEvent('update-status', callback),
saveAsset: (dataBase64, ext, folder) => invoke('save_asset', { dataBase64, ext, folder }),
readAsset: (assetPath) => invoke('read_asset', { path: assetPath }),
}
7 changes: 7 additions & 0 deletions src/components/KeybindsModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export function KeybindsModal({ onClose }: KeybindsModalProps) {
defaultKey: `${defaultMod}+N`,
section: 'app',
},
{
key: 'shortcutVoiceMemo',
label: 'Hold to Record Voice Memo',
storageKey: SETTINGS_KEYS.SHORTCUT_VOICE_MEMO,
defaultKey: `${defaultMod}+Shift+M`,
section: 'app',
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
{
key: 'shortcutSearch',
label: 'Search Notes',
Expand Down
6 changes: 4 additions & 2 deletions src/components/TimersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ const TICK_INTERVAL_MS = 250

import { useState, useEffect, useRef } from 'react'
import { useTimerStore, type Timer } from '../store/useTimerStore'
import { useSettingsStore } from '../store/useSettingsStore'
interface TimerItemProps {
timer: Timer
onRemove: (id: string) => void
Expand Down Expand Up @@ -97,6 +98,7 @@ const QUICK_PRESETS = [
]

export function TimersPage({ onClose }: TimersPageProps) {
const fontFamily = useSettingsStore((s) => s.fontFamily)
const timers = useTimerStore((s) => s.timers)
const addTimer = useTimerStore((s) => s.addTimer)
const removeTimer = useTimerStore((s) => s.removeTimer)
Expand Down Expand Up @@ -149,8 +151,8 @@ export function TimersPage({ onClose }: TimersPageProps) {
}

return (
<div className="timers-overlay" onClick={onClose}>
<div className="timers-panel" onClick={(e) => e.stopPropagation()}>
<div className="timers-overlay" style={{ fontFamily }} onClick={onClose}>
<div className="timers-panel" style={{ fontFamily }} onClick={(e) => e.stopPropagation()}>
<div className="timers-header">
<h2 style={{ margin: 0, color: 'var(--text-color)', fontWeight: 700, fontSize: 17 }}>
<svg
Expand Down
65 changes: 65 additions & 0 deletions src/lib/editor/extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,15 @@ export function useEditorExtensions() {
return true
}

// /memo — voice recording command (currently disabled)
// Voice recording functionality has been removed
// if (lowerLine === '/memo' || lowerLine.startsWith('/memo ')) {
// const from = line.from
// const to = Math.min(line.to + 1, view.state.doc.length)
// view.dispatch({ changes: { from, to, insert: '' } })
// return true
// }

if (
lowerLine.startsWith('/ai') ||
lowerLine.startsWith('/ctx') ||
Expand Down Expand Up @@ -256,6 +265,62 @@ export function useEditorExtensions() {
}
return false
},
paste: (event, view) => {
const items = event.clipboardData?.items
if (!items) return false

for (let i = 0; i < items.length; i++) {
const item = items[i]
if (item && item.type.indexOf('image') !== -1) {
const file = item.getAsFile()
if (file) {
event.preventDefault()
// Capture selection range synchronously before async operations
const selection = view.state.selection.main
const from = selection.from
const to = selection.to
const placeholder = '![uploading...]'

// Insert placeholder immediately to replace selection
view.dispatch({
changes: { from, to, insert: placeholder },
selection: { anchor: from + placeholder.length },
})

const reader = new FileReader()
reader.onload = async (e) => {
const dataUrl = e.target?.result as string
if (dataUrl) {
const ext = file.type.split('/')[1] || 'png'
try {
const path = await window.electronAPI.saveAsset(dataUrl, ext, '.images')
const insertText = `![image](${path})`
// Replace placeholder with actual image embed
view.dispatch({
changes: { from, to: from + placeholder.length, insert: insertText },
selection: { anchor: from + insertText.length },
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to save image asset', err)
// Replace placeholder with error message
view.dispatch({
changes: {
from,
to: from + placeholder.length,
insert: '![upload failed]',
},
})
Comment thread
VariableThe marked this conversation as resolved.
}
}
}
reader.readAsDataURL(file)
return true
}
}
}
return false
},
}),
],
[apiBaseUrl, apiModel, aiSystemPrompt, isHyprland]
Expand Down
Loading
Loading