Skip to content
Open
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
66 changes: 62 additions & 4 deletions src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@
.note svg {
max-width: 100%;
}
.tag{display:inline-block;margin-right:0.4em;padding:0.2em 0.5em;border-radius:4px;border:1px solid var(--color-bg-secondary);background:transparent;cursor:pointer}
.tag.active{background:var(--color-bg-secondary)}
</style>
</head>

Expand All @@ -115,12 +117,16 @@
<textarea id="editor"
placeholder="Ctrl+Enter to save.&#10;Type / to search.&#10;Drag & drop files to attach.&#10;Start links with + to save local copies."></textarea>
<div id="submitContainer"><button id="submitButton">Submit</button></div>
<input id="tagsInput" placeholder="tags (comma separated)" style="width:100%;margin:0.5em 0;padding:0.4em;font-family:monospace;box-sizing:border-box">
<div id="tagPicker" style="margin-bottom:1em"></div>
<div id="notes"></div>

<script>
const editor = document.getElementById('editor');
const notesDiv = document.getElementById('notes');
const tagPicker = document.getElementById('tagPicker');
const submitButton = document.getElementById('submitButton');
const tagsInput = document.getElementById('tagsInput');
let searchTimeout = null;

window.addEventListener('load', async () => {
Expand All @@ -131,11 +137,45 @@
async function displayNotes() {
const params = new URLSearchParams(window.location.search);
const searchQuery = params.get('q');
const tagQuery = params.get('tag');
let response = await fetch('/notes');
if (response.ok) {
const notes = await response.json();
notesDiv.innerHTML = notes

// Build tag picker from notes
const tagCounts = {};
notes.forEach(n => {
if (Array.isArray(n.tags)) {
n.tags.forEach(t => {
tagCounts[t] = (tagCounts[t] || 0) + 1;
})
}
});

// render tag picker
const tags = Object.keys(tagCounts).sort((a,b)=> tagCounts[b]-tagCounts[a]);
tagPicker.innerHTML = '';
if (tags.length > 0) {
const clearBtn = document.createElement('button');
clearBtn.textContent = 'All';
clearBtn.className = 'tag' + (tagQuery ? '' : ' active');
clearBtn.onclick = () => setTag(null);
tagPicker.appendChild(clearBtn);
tags.forEach(t => {
const b = document.createElement('button');
b.textContent = `#${t} (${tagCounts[t]})`;
b.className = 'tag' + (tagQuery === t ? ' active' : '');
b.onclick = () => setTag(t);
tagPicker.appendChild(b);
});
}

// filter notes client-side by search query and tag
const filtered = notes
.filter(note => !searchQuery || note.content.toLowerCase().includes(searchQuery.toLowerCase()))
.filter(note => !tagQuery || (Array.isArray(note.tags) && note.tags.map(x=>x.toLowerCase()).includes(tagQuery.toLowerCase())));

notesDiv.innerHTML = filtered
.map((note, i) => `
<div class="note">
${note.html}
Expand All @@ -144,26 +184,44 @@
[<a href="#" onclick="deleteNote(${i})">delete</a>]
</div>
</div>`)
.reverse() // TODO implement user-specified sorting (reverse chronological by default)
.reverse()
.join('');
}
}

function setTag(tag) {
const newUrl = tag ? `${window.location.pathname}?tag=${encodeURIComponent(tag)}` : window.location.pathname;
window.history.replaceState({}, '', newUrl);
displayNotes();
}

// saves a new note and refreshes the page
async function saveNotes() {
if (!editor.value) {
return;
}

// collect tags from tagsInput (comma separated)
const rawTags = (tagsInput && tagsInput.value) ? tagsInput.value.split(',').map(t=>t.trim()).filter(Boolean) : [];
let contentToSave = editor.value;
if (rawTags.length > 0) {
const tagString = rawTags.map(t => `#${t.replace(/[^A-Za-z0-9_-]/g,'')}`).join(' ');
contentToSave = `${contentToSave}\n\n${tagString}`;
}

const saveResponse = await fetch('/notes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(editor.value)
body: JSON.stringify(contentToSave)
});

// TODO more efficient way to do this than reloading all notes every time
// Clear inputs and refresh when saved
if (saveResponse.ok) {
editor.value = '';
if (tagsInput) tagsInput.value = '';
displayNotes();
} else if (saveResponse.status === 405) {
alert('Server is running in read-only mode.');
}
}

Expand Down
136 changes: 133 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
use axum::{
extract::{DefaultBodyLimit, Multipart, Path, State},
extract::{DefaultBodyLimit, Multipart, Path, State, Query},
http::StatusCode,
response::{Html, IntoResponse},
routing::{get, post},
Expand All @@ -10,6 +10,7 @@ use chrono::Local;
use clap::Parser;
use comrak::{markdown_to_html, Options};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::{
env,
fs::{self},
Expand Down Expand Up @@ -43,20 +44,26 @@ struct Args {
/// Save notes in FILE
#[arg(short = 'f', long, value_name = "FILE", default_value = "notes.md")]
notes_file: PathBuf,
/// Optional positional mode token (use `readonly` to enable read-only mode)
#[arg(value_name = "MODE")]
mode: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
struct Note {
timestamp: String,
content: String,
html: String,
tags: Vec<String>,
}

#[derive(Clone)]
struct AppState {
html: String,
embed_html: String,
notes: Arc<Mutex<Vec<Note>>>,
notes_file: PathBuf,
readonly: bool,
}

const CONTENT_LENGTH_LIMIT: usize = 500 * 1024 * 1024; // allow uploading up to 500mb files... overkill?
Expand All @@ -67,6 +74,13 @@ async fn main() {

let args = Args::parse();

// Use the positional token `readonly` to enable read-only mode, e.g.
// `cargo run -- readonly`
let readonly_flag = match args.mode.as_deref() {
Some("readonly") => true,
_ => false,
};

if let Some(path) = args.base_directory {
if let Err(e) = env::set_current_dir(&path) {
error!("could not change directory to {}: {e}", path.display());
Expand All @@ -88,16 +102,52 @@ async fn main() {
format!("data:image/svg+xml;base64,{favicon}").as_str(),
);

// Create an embed (read-only) version of the HTML by removing the editor
// and the per-note delete link. This is used for iframe embedding and
// also as the served root page when running in read-only mode.
let mut embed_html = html.clone();
// Remove the editor textarea and submit container
if let Some(start) = embed_html.find("<textarea id=\"editor\"") {
if let Some(end) = embed_html[start..].find("</textarea>") {
// include the closing tag
let end_idx = start + end + "</textarea>".len();
// Also remove the submitContainer that follows
if let Some(submit_pos) = embed_html[end_idx..].find("<div id=\"submitContainer\"") {
if let Some(submit_end) = embed_html[end_idx + submit_pos..].find("</div>") {
let submit_end_idx = end_idx + submit_pos + submit_end + "</div>".len();
embed_html.replace_range(start..submit_end_idx, "");
} else {
embed_html.replace_range(start..end_idx, "");
}
} else {
embed_html.replace_range(start..end_idx, "");
}
}
}

// Remove the inline delete link rendered inside displayNotes()
// The snippet in the template is: [<a href="#" onclick="deleteNote(${i})">delete</a>]
embed_html = embed_html.replace("[<a href=\"#\" onclick=\"deleteNote(${i})\">delete</a>]", "");

let notes = Arc::new(Mutex::new(load_notes(&args.notes_file)));

// If running in read-only mode, set an env var so handlers without access to
// State (like the multipart upload handler) can behave accordingly.
if readonly_flag {
std::env::set_var("TEXTPOD_READONLY", "1");
}

let state = AppState {
html,
embed_html: embed_html.clone(),
notes,
notes_file: args.notes_file,
readonly: readonly_flag,
};

let app = Router::new()
.route("/", get(index))
.route("/embed", get(get_embed_with_query))
.route("/notes", get(get_notes).post(save_note))
.route(
"/notes/:index",
Expand Down Expand Up @@ -166,10 +216,12 @@ fn load_notes(file: &PathBuf) -> Vec<Note> {
};

let html = md_to_html(&content);
let tags = extract_tags(&content);
Note {
timestamp,
content: content.to_string(),
html,
tags,
}
})
.collect()
Expand All @@ -180,7 +232,53 @@ fn load_notes(file: &PathBuf) -> Vec<Note> {

// route / (root)
async fn index(State(state): State<AppState>) -> Html<String> {
Html(state.html)
if state.readonly {
Html(state.embed_html.clone())
} else {
Html(state.html.clone())
}
}

// GET /embed?tag=... - return embeddable HTML filtered by tag
async fn get_embed_with_query(
State(state): State<AppState>,
Query(params): Query<HashMap<String, String>>,
) -> Html<String> {
let tag = params.get("tag").map(|s| s.to_lowercase());

let notes = state.notes.lock().unwrap();
let filtered: Vec<Note> = notes
.iter()
.cloned()
.filter(|n| match &tag {
Some(t) => n.tags.iter().any(|tg| tg == t),
None => true,
})
.collect();

// Minimal HTML page for embedding filtered notes. Copy core styles from index.html.
let mut out = String::new();
out.push_str("<!DOCTYPE html><html><head><meta charset=\"utf-8\">\n");
out.push_str("<meta name=\"color-scheme\" content=\"light dark\" />\n");
out.push_str("<style>");
out.push_str(".note{margin-bottom:1.75em;padding-top:0.25em}.note .noteMetadata{font-size:0.9em;font-family:monospace;color:#666}.note img,.note iframe,.note video,.note audio,.note embed,.note svg{max-width:100%}");
out.push_str("</style></head><body>\n");

out.push_str("<div id=\"notes\">\n");
for note in filtered.iter().rev() {
out.push_str("<div class=\"note\">\n");
out.push_str(&note.html);
out.push_str("<div class=\"noteMetadata\">\n");
out.push_str(&format!("<time datetime=\"{}\">{}</time>", note.timestamp, note.timestamp));
if !note.tags.is_empty() {
out.push_str(" &nbsp; ");
out.push_str(&note.tags.iter().map(|t| format!("#{}", t)).collect::<Vec<_>>().join(" "));
}
out.push_str("</div></div>\n");
}
out.push_str("</div></body></html>");

Html(out)
}

// GET /notes
Expand Down Expand Up @@ -210,6 +308,9 @@ async fn delete_note_by_index(
State(state): State<AppState>,
Path(index): Path<usize>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if state.readonly {
return Err((StatusCode::METHOD_NOT_ALLOWED, String::from("read-only mode")));
}
let mut notes = state.notes.lock().unwrap();
if index >= notes.len() {
return Err((
Expand Down Expand Up @@ -241,6 +342,9 @@ async fn save_note(
State(state): State<AppState>,
Json(content): Json<String>,
) -> Result<(), StatusCode> {
if state.readonly {
return Err(StatusCode::METHOD_NOT_ALLOWED);
}
let mut content = content.clone();

// Replace "---" with "<hr>" in the content
Expand All @@ -261,11 +365,13 @@ async fn save_note(
}

let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
let html = md_to_html(&content); // Changed to pass a reference
let html = md_to_html(&content);
let tags = extract_tags(&content);
let note = Note {
timestamp: timestamp.clone(),
content: content.clone(),
html,
tags,
};

state.notes.lock().unwrap().push(note);
Expand Down Expand Up @@ -341,6 +447,12 @@ async fn save_note(

// route POST /upload
async fn upload_file(mut multipart: Multipart) -> Result<Json<String>, StatusCode> {
// Check env var set at startup for readonly mode; this handler doesn't
// currently receive State, so use env var as a pragmatic signal.
if std::env::var("TEXTPOD_READONLY").is_ok() {
return Err(StatusCode::METHOD_NOT_ALLOWED);
}

while let Some(field) = multipart.next_field().await.unwrap() {
let name = field.file_name().unwrap().to_string();
let data = field.bytes().await.unwrap();
Expand Down Expand Up @@ -420,3 +532,21 @@ fn url_to_safe_filename(url: &str) -> String {

safe_name.trim_matches(|c| c == '.' || c == ' ').to_string()
}

fn extract_tags(content: &str) -> Vec<String> {
content
.split_whitespace()
.filter_map(|w| {
if w.starts_with('#') && w.len() > 1 {
// strip leading '#' and trailing punctuation
let mut tag = w.trim_start_matches('#').trim().to_string();
// remove surrounding punctuation
tag = tag.trim_matches(|c: char| !c.is_alphanumeric() && c != '-' && c != '_').to_string();
if !tag.is_empty() {
return Some(tag.to_lowercase());
}
}
None
})
.collect()
}
Loading