Remove [[wiki-links]], backlinks and the graph
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Failing after 6s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 8s
Desktop (Tauri) / Tauri desktop (Linux) (push) Failing after 31s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Failing after 37s
Desktop (Tauri) / Update manifest (push) Skipped
Android / Kotlin + Rust (APK) (push) Failing after 1m56s
Operator, 2026-08-22 (note 2897): ThoughtSync is an intermediary surface. You
write here because it's easy — a notebook in your pocket — and later you recall
the thing and go finish it somewhere else. Recall is the product; organization
is secondary. A linking system is organization, and it isn't what this is for.
So: `[[wiki-links]]`, backlinks, the `[[` autocomplete, the note_links table,
`/api/notes/link-search`, `/api/notes/<id>/backlinks`, the whole graph blueprint
and GraphView. Rust core loses `extract_links`, `backlinks`, `link_search` and
`create_titled`; the desktop loses the three Tauri commands that exposed them.
This subsumes 982d24c rather than reverting it. That commit bound links to a
note id so a rename would stop rewriting other notes' bodies — real infra, but
infra for a feature that is now gone, and nothing it added survives. Alembic
0023 stays in the chain anyway: it shipped in an image and may already be
applied, and deleting an applied revision strands a database's version pointer.
0024 drops the table and takes the column with it. The history stays honest
about the fact that it existed for a day.
Two things deliberately kept, because they were serving recall and only
incidentally serving links:
- `/api/notes/titles` and the titles store. The command palette lists them so
you can jump to a note by name. `resolve()` — the name→note lookup that only
linking needed — is gone.
- `display_title`. Every note still has a name for search results and export
filenames. What that name is FOR changed; that it exists did not.
`notes/links.py` is now `notes/tags.py`, holding the #tag→label reconciliation
it always also owned. A file called links.py with no links in it would have been
exactly the drift this removal is meant to end.
Also swept out on the way: `_escape_like`, whose only caller was link-search,
and the `graph` icon. Nothing lost that a person typed — note_links was always
derived, and the `[[text]]` is still sitting in every body it was written in.
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
"""drop note_links — [[wiki-links]] are removed (note 2897)
|
||||||
|
|
||||||
|
Revision ID: 0024
|
||||||
|
Revises: 0023
|
||||||
|
Create Date: 2026-08-22
|
||||||
|
|
||||||
|
ThoughtSync is an intermediary surface for capture and recall; a linking system is
|
||||||
|
organization, which is not what it is for. Backlinks, the graph and the name index
|
||||||
|
went with it.
|
||||||
|
|
||||||
|
0023 (which added `note_links.target_id`) is deliberately left in the chain rather
|
||||||
|
than deleted. It shipped in an image and may already be applied, and removing an
|
||||||
|
applied revision would strand a database's alembic_version pointer. So the column is
|
||||||
|
dropped here along with the table it lived on, and the history stays honest about the
|
||||||
|
fact that it existed for a day.
|
||||||
|
|
||||||
|
No down-migration data concern: note_links was always DERIVED from note bodies. The
|
||||||
|
`[[text]]` is still sitting in every body it was written in; nothing a person typed is
|
||||||
|
lost by this.
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects import postgresql
|
||||||
|
|
||||||
|
revision = "0024"
|
||||||
|
down_revision = "0023"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.drop_table("note_links")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"note_links",
|
||||||
|
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"source_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"target_id",
|
||||||
|
postgresql.UUID(as_uuid=True),
|
||||||
|
sa.ForeignKey("notes.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("target_norm", sa.Text(), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_note_links_target", "note_links", ["target_norm"])
|
||||||
|
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
|
||||||
@@ -1,38 +1,14 @@
|
|||||||
//! Deriving `[[wiki-links]]` and `#tags` from a note's body — the local mirror of
|
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||||
//! what the server computes on save. Pure string scanning (no regex dependency),
|
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||||
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
|
//! frontend's inline rules (see frontend notes/markdown.ts):
|
||||||
//!
|
//!
|
||||||
//! - `[[link]]`: `[[` … `]]` with no brackets inside, inner text trimmed. Used to
|
|
||||||
//! compute backlinks at query time (links are derived, never stored/synced).
|
|
||||||
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
//! - `#tag`: `#` at a word boundary followed by tag characters (letter first).
|
||||||
//! On save these become labels attached with `via_tag = true`.
|
//! On save these become labels attached with `via_tag = true`.
|
||||||
//!
|
//!
|
||||||
//! Both dedupe case-insensitively, preserving first-seen order.
|
//! Dedupes case-insensitively, preserving first-seen order.
|
||||||
|
//!
|
||||||
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
|
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||||
pub fn extract_links(body: &str) -> Vec<String> {
|
//! capture-and-recall surface, and a linking system is organization.
|
||||||
let bytes = body.as_bytes();
|
|
||||||
let mut out: Vec<String> = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while i + 1 < bytes.len() {
|
|
||||||
if bytes[i] == b'[' && bytes[i + 1] == b'[' {
|
|
||||||
if let Some(rel) = body[i + 2..].find("]]") {
|
|
||||||
let inner = &body[i + 2..i + 2 + rel];
|
|
||||||
// Mirror the frontend's `[^[\]]+`: no stray brackets inside.
|
|
||||||
if !inner.contains('[') && !inner.contains(']') {
|
|
||||||
let t = inner.trim();
|
|
||||||
if !t.is_empty() {
|
|
||||||
push_unique(&mut out, t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 2 + rel + 2;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||||
@@ -73,28 +49,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn links_basic_and_trim() {
|
|
||||||
assert_eq!(
|
|
||||||
extract_links("see [[ Alpha ]] and [[Beta]]"),
|
|
||||||
vec!["Alpha", "Beta"]
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn links_dedupe_case_insensitive_first_seen() {
|
|
||||||
assert_eq!(extract_links("[[Note]] then [[note]] again"), vec!["Note"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn links_ignore_malformed_and_nested_brackets() {
|
|
||||||
assert_eq!(
|
|
||||||
extract_links("[[a[b]] [[]] [ [x] ] plain"),
|
|
||||||
Vec::<String>::new()
|
|
||||||
);
|
|
||||||
assert_eq!(extract_links("[[ok]] [[a]b]]"), vec!["ok"]);
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tags_basic() {
|
fn tags_basic() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
@@ -116,7 +70,6 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_body() {
|
fn empty_body() {
|
||||||
assert!(extract_links("").is_empty());
|
|
||||||
assert!(extract_tags("").is_empty());
|
assert!(extract_tags("").is_empty());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ pub struct Note {
|
|||||||
pub id: String,
|
pub id: String,
|
||||||
pub title: Option<String>,
|
pub title: Option<String>,
|
||||||
/// title if set, else the note's first body line — always present, so body-only
|
/// title if set, else the note's first body line — always present, so body-only
|
||||||
/// notes are still nameable and `[[link]]`-able. Derived, never stored.
|
/// notes still have something to be called. Derived, never stored.
|
||||||
pub display_title: String,
|
pub display_title: String,
|
||||||
pub body: String,
|
pub body: String,
|
||||||
pub color: String,
|
pub color: String,
|
||||||
@@ -94,12 +94,6 @@ pub struct TitleEntry {
|
|||||||
pub title: String,
|
pub title: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
|
||||||
pub struct Backlink {
|
|
||||||
pub id: String,
|
|
||||||
pub title: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
pub struct SavedFilter {
|
pub struct SavedFilter {
|
||||||
pub id: String,
|
pub id: String,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
|
//! Local SQLite schema + migrations. The schema mirrors the note/label model so an
|
||||||
//! offline note can later sync 1:1 with the server. Each syncable row carries local
|
//! offline note can later sync 1:1 with the server. Each syncable row carries local
|
||||||
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
|
//! `sync_revision` + `dirty` bookkeeping (consumed by the sync engine in M10.7);
|
||||||
//! `[[links]]` are NOT stored (derived at query time), matching docs/sync.md.
|
//! `#tags` are NOT stored as such (derived at query time into labels), matching
|
||||||
|
//! docs/sync.md.
|
||||||
//!
|
//!
|
||||||
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
//! Migrations are gated on `PRAGMA user_version`; bump it and add a block per change.
|
||||||
|
|
||||||
|
|||||||
@@ -350,61 +350,6 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
|||||||
ids.iter().map(|id| load_note(conn, id)).collect()
|
ids.iter().map(|id| load_note(conn, id)).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn backlinks(conn: &Connection, id: &str) -> rusqlite::Result<Vec<Backlink>> {
|
|
||||||
let target: String = {
|
|
||||||
let (t, b): (Option<String>, String) =
|
|
||||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
|
||||||
})?;
|
|
||||||
display_title(t.as_deref(), &b)
|
|
||||||
};
|
|
||||||
if target.is_empty() {
|
|
||||||
return Ok(Vec::new());
|
|
||||||
}
|
|
||||||
let mut stmt =
|
|
||||||
conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0 AND id != ?1")?;
|
|
||||||
let rows = stmt.query_map([id], |r| {
|
|
||||||
let nid: String = r.get(0)?;
|
|
||||||
let t: Option<String> = r.get(1)?;
|
|
||||||
let b: String = r.get(2)?;
|
|
||||||
Ok((nid, t, b))
|
|
||||||
})?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
let (nid, t, b) = row?;
|
|
||||||
if derive::extract_links(&b)
|
|
||||||
.iter()
|
|
||||||
.any(|l| l.eq_ignore_ascii_case(&target))
|
|
||||||
{
|
|
||||||
out.push(Backlink {
|
|
||||||
id: nid,
|
|
||||||
title: display_title(t.as_deref(), &b),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn link_search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<TitleEntry>> {
|
|
||||||
let ql = q.trim().to_lowercase();
|
|
||||||
let mut stmt = conn.prepare("SELECT id, title, body FROM notes WHERE trashed = 0")?;
|
|
||||||
let rows = stmt.query_map([], |r| {
|
|
||||||
let id: String = r.get(0)?;
|
|
||||||
let t: Option<String> = r.get(1)?;
|
|
||||||
let b: String = r.get(2)?;
|
|
||||||
Ok((id, t, b))
|
|
||||||
})?;
|
|
||||||
let mut out = Vec::new();
|
|
||||||
for row in rows {
|
|
||||||
let (id, t, b) = row?;
|
|
||||||
let dt = display_title(t.as_deref(), &b);
|
|
||||||
if ql.is_empty() || dt.to_lowercase().contains(&ql) {
|
|
||||||
out.push(TitleEntry { id, title: dt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- notes: write -----------------------------------------------------------
|
// ---- notes: write -----------------------------------------------------------
|
||||||
|
|
||||||
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Result<Note> {
|
||||||
@@ -434,30 +379,6 @@ pub fn create_note(conn: &Connection, input: &NoteCreateInput) -> rusqlite::Resu
|
|||||||
load_note(conn, &id)
|
load_note(conn, &id)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_titled(conn: &Connection, title: &str) -> rusqlite::Result<Note> {
|
|
||||||
let input = NoteCreateInput {
|
|
||||||
title: title.to_string(),
|
|
||||||
body: String::new(),
|
|
||||||
color: "default".to_string(),
|
|
||||||
kind: None,
|
|
||||||
items: None,
|
|
||||||
};
|
|
||||||
create_note(conn, &input)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn snapshot_revision(conn: &Connection, id: &str) -> rusqlite::Result<()> {
|
|
||||||
let (title, body): (Option<String>, String) =
|
|
||||||
conn.query_row("SELECT title, body FROM notes WHERE id = ?1", [id], |r| {
|
|
||||||
Ok((r.get(0)?, r.get(1)?))
|
|
||||||
})?;
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO note_revisions (id, note_id, title, body, created_at) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
||||||
params![new_id(), id, title, body, now()],
|
|
||||||
)?;
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// PATCH semantics: apply exactly the fields present in `changes`.
|
|
||||||
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
|
pub fn update_note(conn: &Connection, id: &str, changes: &Value) -> rusqlite::Result<Note> {
|
||||||
let obj = changes
|
let obj = changes
|
||||||
.as_object()
|
.as_object()
|
||||||
|
|||||||
@@ -67,12 +67,6 @@ pub fn notes_create(input: NoteCreateInput, db: State<'_, Db>) -> Result<Note, S
|
|||||||
store::create_note(&conn, &input).map_err(|e| e.to_string())
|
store::create_note(&conn, &input).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn notes_create_titled(title: String, db: State<'_, Db>) -> Result<Note, String> {
|
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
store::create_titled(&conn, &title).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
|
pub fn notes_update(id: String, changes: Value, db: State<'_, Db>) -> Result<Note, String> {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
@@ -202,18 +196,6 @@ pub fn notes_search(q: String, db: State<'_, Db>) -> Result<Vec<Note>, String> {
|
|||||||
store::search(&conn, &q).map_err(|e| e.to_string())
|
store::search(&conn, &q).map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn notes_backlinks(id: String, db: State<'_, Db>) -> Result<Vec<Backlink>, String> {
|
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
store::backlinks(&conn, &id).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
|
||||||
pub fn notes_link_search(q: String, db: State<'_, Db>) -> Result<Vec<TitleEntry>, String> {
|
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
|
||||||
store::link_search(&conn, &q).map_err(|e| e.to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, String> {
|
||||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ pub fn run() {
|
|||||||
commands::local::notes_list,
|
commands::local::notes_list,
|
||||||
commands::local::notes_get,
|
commands::local::notes_get,
|
||||||
commands::local::notes_create,
|
commands::local::notes_create,
|
||||||
commands::local::notes_create_titled,
|
|
||||||
commands::local::notes_update,
|
commands::local::notes_update,
|
||||||
commands::local::notes_complete_reminder,
|
commands::local::notes_complete_reminder,
|
||||||
commands::local::notes_snooze_reminder,
|
commands::local::notes_snooze_reminder,
|
||||||
@@ -121,8 +120,6 @@ pub fn run() {
|
|||||||
commands::local::notes_reminders,
|
commands::local::notes_reminders,
|
||||||
commands::local::notes_titles,
|
commands::local::notes_titles,
|
||||||
commands::local::notes_search,
|
commands::local::notes_search,
|
||||||
commands::local::notes_backlinks,
|
|
||||||
commands::local::notes_link_search,
|
|
||||||
commands::local::labels_list,
|
commands::local::labels_list,
|
||||||
commands::local::labels_create,
|
commands::local::labels_create,
|
||||||
commands::local::labels_rename,
|
commands::local::labels_rename,
|
||||||
|
|||||||
+4
-4
@@ -122,9 +122,9 @@ as `?since=`. `since=0` (or absent) is a **full initial sync**.
|
|||||||
so pulling the note re-syncs the whole thing.
|
so pulling the note re-syncs the whole thing.
|
||||||
- **Label** — the label *catalog* (name + color) syncs as its own entity so a
|
- **Label** — the label *catalog* (name + color) syncs as its own entity so a
|
||||||
rename/recolor/delete propagates independently of notes.
|
rename/recolor/delete propagates independently of notes.
|
||||||
- **Derived, NOT synced:** `[[wiki-links]]` and `#tags` are parsed from the note
|
- **Derived, NOT synced:** `#tags` are parsed from the note body. Clients recompute
|
||||||
body. Clients recompute them locally; the server recomputes them on push. They
|
them locally; the server recomputes them on push. They never travel over the wire.
|
||||||
never travel over the wire.
|
(`[[wiki-links]]` were derived the same way until they were removed — note 2897.)
|
||||||
- **Attachment blobs** sync by id over the existing upload/download routes (see
|
- **Attachment blobs** sync by id over the existing upload/download routes (see
|
||||||
Attachments below); only their metadata rides the delta feed.
|
Attachments below); only their metadata rides the delta feed.
|
||||||
|
|
||||||
@@ -201,7 +201,7 @@ Body: `{ "changes": [ ... ] }` (max 1000 per batch). Each change:
|
|||||||
- **Whole-note semantics.** A note upsert carries the client's *full* current
|
- **Whole-note semantics.** A note upsert carries the client's *full* current
|
||||||
state (not a partial patch) — the server overwrites all scalar fields, replaces
|
state (not a partial patch) — the server overwrites all scalar fields, replaces
|
||||||
items, and sets manual label memberships from `label_ids` (tag-sourced labels
|
items, and sets manual label memberships from `label_ids` (tag-sourced labels
|
||||||
are re-derived from the body). `[[links]]`/`#tags` are recomputed server-side.
|
are re-derived from the body). `#tags` are recomputed server-side.
|
||||||
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
|
- **`op: "delete"`** purges (tombstones) the row. Trashing is just an upsert with
|
||||||
`trashed: true`.
|
`trashed: true`.
|
||||||
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
|
- **Labels:** `{entity: "label", op: "upsert"|"delete", id, edited_at, name,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import type { Device } from "../stores/devices";
|
|||||||
import type { TitleEntry } from "../stores/titles";
|
import type { TitleEntry } from "../stores/titles";
|
||||||
import type { User } from "../stores/session";
|
import type { User } from "../stores/session";
|
||||||
import type { PublicConfig } from "../stores/config";
|
import type { PublicConfig } from "../stores/config";
|
||||||
import type { Backlink, DeviceToken, ImportResult, Repo } from "./repo";
|
import type { DeviceToken, ImportResult, Repo } from "./repo";
|
||||||
|
|
||||||
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
const NEEDS_SERVER = "That's not available offline — connect a server to use it.";
|
||||||
|
|
||||||
@@ -51,7 +51,6 @@ export const local: Repo = {
|
|||||||
list: (query) => invoke<Note[]>("notes_list", { query }),
|
list: (query) => invoke<Note[]>("notes_list", { query }),
|
||||||
get: (id) => invoke<Note>("notes_get", { id }),
|
get: (id) => invoke<Note>("notes_get", { id }),
|
||||||
create: (input) => invoke<Note>("notes_create", { input }),
|
create: (input) => invoke<Note>("notes_create", { input }),
|
||||||
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
|
|
||||||
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
|
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
|
||||||
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||||
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||||
@@ -73,8 +72,6 @@ export const local: Repo = {
|
|||||||
reminders: () => invoke<Note[]>("notes_reminders"),
|
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||||
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||||
search: (q) => invoke<Note[]>("notes_search", { q }),
|
search: (q) => invoke<Note[]>("notes_search", { q }),
|
||||||
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
|
|
||||||
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
|
|
||||||
},
|
},
|
||||||
|
|
||||||
savedFilters: {
|
savedFilters: {
|
||||||
|
|||||||
@@ -49,10 +49,6 @@ export interface ChecklistItemChanges {
|
|||||||
checked?: boolean;
|
checked?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Backlink {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportResult {
|
export interface ImportResult {
|
||||||
source: string;
|
source: string;
|
||||||
@@ -97,7 +93,6 @@ export interface NotesRepo {
|
|||||||
list(query: NoteListQuery): Promise<Note[]>;
|
list(query: NoteListQuery): Promise<Note[]>;
|
||||||
get(id: string): Promise<Note>;
|
get(id: string): Promise<Note>;
|
||||||
create(input: NoteCreateInput): Promise<Note>;
|
create(input: NoteCreateInput): Promise<Note>;
|
||||||
createTitled(title: string): Promise<Note>;
|
|
||||||
update(id: string, changes: NoteChanges): Promise<Note>;
|
update(id: string, changes: NoteChanges): Promise<Note>;
|
||||||
completeReminder(id: string): Promise<Note>;
|
completeReminder(id: string): Promise<Note>;
|
||||||
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
||||||
@@ -119,8 +114,6 @@ export interface NotesRepo {
|
|||||||
reminders(): Promise<Note[]>;
|
reminders(): Promise<Note[]>;
|
||||||
titles(): Promise<TitleEntry[]>;
|
titles(): Promise<TitleEntry[]>;
|
||||||
search(q: string): Promise<Note[]>;
|
search(q: string): Promise<Note[]>;
|
||||||
backlinks(id: string): Promise<Backlink[]>;
|
|
||||||
linkSearch(q: string): Promise<TitleEntry[]>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavedFiltersRepo {
|
export interface SavedFiltersRepo {
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
|
|||||||
import type { User } from "../stores/session";
|
import type { User } from "../stores/session";
|
||||||
import type { PublicConfig } from "../stores/config";
|
import type { PublicConfig } from "../stores/config";
|
||||||
import type {
|
import type {
|
||||||
Backlink,
|
|
||||||
DeviceToken,
|
DeviceToken,
|
||||||
ImportResult,
|
ImportResult,
|
||||||
NoteChanges,
|
NoteChanges,
|
||||||
@@ -80,7 +79,6 @@ export const rest: Repo = {
|
|||||||
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
|
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
|
||||||
get: (id) => api.get<Note>(`/api/notes/${id}`),
|
get: (id) => api.get<Note>(`/api/notes/${id}`),
|
||||||
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
|
create: (input: NoteCreateInput) => api.post<Note>("/api/notes", input),
|
||||||
createTitled: (title) => api.post<Note>("/api/notes", { title, body: "" }),
|
|
||||||
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
|
update: (id, changes: NoteChanges) => api.patch<Note>(`/api/notes/${id}`, changes),
|
||||||
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
||||||
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
||||||
@@ -103,9 +101,6 @@ export const rest: Repo = {
|
|||||||
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
reminders: async () => (await api.get<{ notes: Note[] }>("/api/notes/reminders")).notes,
|
||||||
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
|
titles: async () => (await api.get<{ titles: TitleEntry[] }>("/api/notes/titles")).titles,
|
||||||
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
|
search: async (q) => (await api.get<{ notes: Note[] }>(`/api/notes/search?q=${encodeURIComponent(q)}`)).notes,
|
||||||
backlinks: async (id) => (await api.get<{ backlinks: Backlink[] }>(`/api/notes/${id}/backlinks`)).backlinks,
|
|
||||||
linkSearch: async (q) =>
|
|
||||||
(await api.get<{ results: TitleEntry[] }>(`/api/notes/link-search?q=${encodeURIComponent(q)}`)).results,
|
|
||||||
},
|
},
|
||||||
|
|
||||||
savedFilters: {
|
savedFilters: {
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ const shortcuts = [
|
|||||||
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
|
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
|
||||||
{ label: "Search", keys: ["/"] },
|
{ label: "Search", keys: ["/"] },
|
||||||
{ label: "Go to Board", keys: ["g", "b"] },
|
{ label: "Go to Board", keys: ["g", "b"] },
|
||||||
{ label: "Go to Graph", keys: ["g", "g"] },
|
|
||||||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||||||
{ label: "Go to Timeline", keys: ["g", "t"] },
|
{ label: "Go to Timeline", keys: ["g", "t"] },
|
||||||
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
|
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
|
||||||
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
|
|||||||
void router.push("/");
|
void router.push("/");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key === "g") {
|
|
||||||
e.preventDefault();
|
|
||||||
void router.push("/graph");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (e.key === "r") {
|
if (e.key === "r") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void router.push("/reminders");
|
void router.push("/reminders");
|
||||||
@@ -207,7 +201,7 @@ watch(
|
|||||||
*
|
*
|
||||||
* Keyed off the route name rather than each view declaring its own title, so the
|
* Keyed off the route name rather than each view declaring its own title, so the
|
||||||
* label sits in one place and can't go missing (the board and search never had one)
|
* label sits in one place and can't go missing (the board and search never had one)
|
||||||
* or drift in styling (timeline, reminders and graph each had their own h1).
|
* or drift in styling (timeline and reminders each had their own h1).
|
||||||
*
|
*
|
||||||
* A label lens is named by the label itself — "Groceries" is what the user came
|
* A label lens is named by the label itself — "Groceries" is what the user came
|
||||||
* looking for; "Label" would tell them nothing they didn't already know.
|
* looking for; "Label" would tell them nothing they didn't already know.
|
||||||
@@ -224,8 +218,6 @@ const lensName = computed<string>(() => {
|
|||||||
return "Timeline";
|
return "Timeline";
|
||||||
case "reminders":
|
case "reminders":
|
||||||
return "Reminders";
|
return "Reminders";
|
||||||
case "graph":
|
|
||||||
return "Graph";
|
|
||||||
case "label":
|
case "label":
|
||||||
// The store may not have loaded yet on a deep link; fall back rather than
|
// The store may not have loaded yet on a deep link; fall back rather than
|
||||||
// flashing an empty slot.
|
// flashing an empty slot.
|
||||||
@@ -403,9 +395,6 @@ async function signOut() {
|
|||||||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||||||
<Icon name="note" /> Notes
|
<Icon name="note" /> Notes
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
<RouterLink to="/graph" class="nav-link" :class="route.name === 'graph' ? 'nav-link-active' : ''">
|
|
||||||
<Icon name="graph" /> Graph
|
|
||||||
</RouterLink>
|
|
||||||
|
|
||||||
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
<div class="mt-3 flex items-center justify-between px-3 pb-1">
|
||||||
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
<span class="text-xs font-semibold uppercase tracking-wide text-neutral-400">Labels</span>
|
||||||
@@ -522,7 +511,7 @@ async function signOut() {
|
|||||||
BoardView, so keying on the route would remount it — blanking the board
|
BoardView, so keying on the route would remount it — blanking the board
|
||||||
and refetching, which is precisely the page-change feeling this is meant
|
and refetching, which is precisely the page-change feeling this is meant
|
||||||
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
to remove. Unkeyed, Vue only transitions when the component TYPE changes
|
||||||
(board ↔ search ↔ timeline ↔ graph), and moving between the board's own
|
(board ↔ search ↔ timeline), and moving between the board's own
|
||||||
lenses stays an in-place reflow that NoteGrid animates. -->
|
lenses stays an in-place reflow that NoteGrid animates. -->
|
||||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||||
<RouterView v-slot="{ Component }">
|
<RouterView v-slot="{ Component }">
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
|
|||||||
const list: Row[] = [
|
const list: Row[] = [
|
||||||
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
|
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
|
||||||
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
|
{ id: "cmd:board", label: "Go to Board", hint: "Navigate", run: () => go("/") },
|
||||||
{ id: "cmd:graph", label: "Go to Graph", hint: "Navigate", run: () => go("/graph") },
|
|
||||||
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
{ id: "cmd:reminders", label: "Go to Reminders", hint: "Navigate", run: () => go("/reminders") },
|
||||||
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
|
{ id: "cmd:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
|
||||||
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
|
{ id: "cmd:archive", label: "Go to Archive", hint: "Navigate", run: () => go("/archive") },
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ const paths: Record<string, string> = {
|
|||||||
check: '<path d="M20 6 9 17l-5-5"/>',
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
||||||
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||||
graph: '<circle cx="18" cy="5" r="3"/><circle cx="6" cy="12" r="3"/><circle cx="18" cy="19" r="3"/><line x1="8.59" x2="15.42" y1="13.51" y2="17.49"/><line x1="15.41" x2="8.59" y1="6.51" y2="10.49"/>',
|
|
||||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||||
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
|
grip: '<circle cx="9" cy="5" r="1" fill="currentColor"/><circle cx="9" cy="12" r="1" fill="currentColor"/><circle cx="9" cy="19" r="1" fill="currentColor"/><circle cx="15" cy="5" r="1" fill="currentColor"/><circle cx="15" cy="12" r="1" fill="currentColor"/><circle cx="15" cy="19" r="1" fill="currentColor"/>',
|
||||||
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
calendar: '<path d="M8 2v4"/><path d="M16 2v4"/><rect width="18" height="18" x="3" y="4" rx="2"/><path d="M3 10h18"/>',
|
||||||
|
|||||||
@@ -1,65 +1,17 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { useNotesStore, type NoteLinkRef } from "../stores/notes";
|
|
||||||
import { useTitlesStore } from "../stores/titles";
|
|
||||||
import type { InlineToken } from "../notes/markdown";
|
import type { InlineToken } from "../notes/markdown";
|
||||||
|
|
||||||
const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
|
// Emphasis and code only. `[[wiki-links]]` were the one token type that needed a
|
||||||
|
// router, a store and a resolver behind it; they are gone (note 2897), and so is all
|
||||||
const router = useRouter();
|
// of that.
|
||||||
const notes = useNotesStore();
|
defineProps<{ tokens: InlineToken[] }>();
|
||||||
const titles = useTitlesStore();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Where a [[link]] token actually points, according to the server.
|
|
||||||
*
|
|
||||||
* Keyed on the normalized written text, which is what survives in the body — the
|
|
||||||
* server resolved it to an id when the link was saved, so this keeps working after
|
|
||||||
* the target has been renamed and the written text has gone stale.
|
|
||||||
*/
|
|
||||||
const bound = computed(() => {
|
|
||||||
const map = new Map<string, NoteLinkRef>();
|
|
||||||
for (const l of props.links ?? []) map.set(l.norm, l);
|
|
||||||
return map;
|
|
||||||
});
|
|
||||||
|
|
||||||
/** What to SHOW for a link: the target's current name, else the text as written. */
|
|
||||||
function label(token: string): string {
|
|
||||||
return bound.value.get(token.trim().toLowerCase())?.title ?? token;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open the target via the board's ?open=<id> mechanism, creating the note first if
|
|
||||||
// the link names one that doesn't exist — which is a supported way to make a note.
|
|
||||||
async function follow(token: string) {
|
|
||||||
const hit = bound.value.get(token.trim().toLowerCase());
|
|
||||||
if (hit) {
|
|
||||||
void router.push({ path: "/", query: { open: hit.id } });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// No server binding: either this is running offline against the local store, or
|
|
||||||
// the link genuinely resolves to nothing. The name index answers the first case.
|
|
||||||
await titles.load();
|
|
||||||
const byName = titles.resolve(token);
|
|
||||||
const id = byName ? byName.id : (await notes.createTitled(token)).id;
|
|
||||||
if (!byName) await titles.reload();
|
|
||||||
void router.push({ path: "/", query: { open: id } });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
||||||
spaces are preserved and no extra spaces are introduced. -->
|
spaces are preserved and no extra spaces are introduced. -->
|
||||||
<template
|
<template
|
||||||
><template v-for="(t, i) in tokens" :key="i"
|
><template v-for="(t, i) in tokens" :key="i"
|
||||||
><span
|
><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||||
v-if="t.type === 'link'"
|
|
||||||
role="link"
|
|
||||||
tabindex="0"
|
|
||||||
class="cursor-pointer font-medium text-brand-700 underline-offset-2 hover:underline dark:text-brand"
|
|
||||||
@click.stop="follow(t.value)"
|
|
||||||
@keydown.enter.stop.prevent="follow(t.value)"
|
|
||||||
>{{ label(t.value) }}</span
|
|
||||||
><strong v-else-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
|
||||||
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
||||||
><code
|
><code
|
||||||
v-else-if="t.type === 'code'"
|
v-else-if="t.type === 'code'"
|
||||||
|
|||||||
@@ -2,38 +2,35 @@
|
|||||||
import { computed } from "vue";
|
import { computed } from "vue";
|
||||||
import { parseMarkdown } from "../notes/markdown";
|
import { parseMarkdown } from "../notes/markdown";
|
||||||
import MarkdownInline from "./MarkdownInline.vue";
|
import MarkdownInline from "./MarkdownInline.vue";
|
||||||
import type { NoteLinkRef } from "../stores/notes";
|
|
||||||
|
|
||||||
// `links` is the owning note's resolved [[links]], passed straight through to every
|
const props = defineProps<{ text: string }>();
|
||||||
// inline run — only MarkdownInline uses it, but only this component knows the note.
|
|
||||||
const props = defineProps<{ text: string; links?: NoteLinkRef[] }>();
|
|
||||||
const blocks = computed(() => parseMarkdown(props.text));
|
const blocks = computed(() => parseMarkdown(props.text));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-1.5 break-words">
|
<div class="space-y-1.5 break-words">
|
||||||
<template v-for="(b, i) in blocks" :key="i">
|
<template v-for="(b, i) in blocks" :key="i">
|
||||||
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h3>
|
<h3 v-if="b.type === 'h1'" class="text-base font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h3>
|
||||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h4>
|
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" /></h4>
|
||||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
|
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||||
<blockquote
|
<blockquote
|
||||||
v-else-if="b.type === 'quote'"
|
v-else-if="b.type === 'quote'"
|
||||||
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
class="whitespace-pre-wrap border-l-2 border-neutral-300 pl-2 text-neutral-600 dark:border-neutral-600 dark:text-neutral-400"
|
||||||
>
|
>
|
||||||
<MarkdownInline :tokens="b.inline ?? []" :links="links" />
|
<MarkdownInline :tokens="b.inline ?? []" />
|
||||||
</blockquote>
|
</blockquote>
|
||||||
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
<ul v-else-if="b.type === 'ul'" class="list-disc space-y-0.5 pl-5">
|
||||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||||
</ul>
|
</ul>
|
||||||
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
<ol v-else-if="b.type === 'ol'" class="list-decimal space-y-0.5 pl-5">
|
||||||
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" :links="links" /></li>
|
<li v-for="(it, j) in b.items ?? []" :key="j"><MarkdownInline :tokens="it" /></li>
|
||||||
</ol>
|
</ol>
|
||||||
<pre
|
<pre
|
||||||
v-else-if="b.type === 'pre'"
|
v-else-if="b.type === 'pre'"
|
||||||
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
class="overflow-x-auto whitespace-pre-wrap rounded-md bg-black/5 p-2 font-mono text-xs dark:bg-white/10"
|
||||||
>{{ b.value ?? "" }}</pre
|
>{{ b.value ?? "" }}</pre
|
||||||
>
|
>
|
||||||
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></p>
|
<p v-else class="whitespace-pre-wrap"><MarkdownInline :tokens="b.inline ?? []" /></p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
|||||||
{{ note.title }}
|
{{ note.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
<div v-if="note.body" class="text-sm text-neutral-700 dark:text-neutral-300">
|
||||||
<MarkdownText :text="note.body" :links="note.links" />
|
<MarkdownText :text="note.body" />
|
||||||
</div>
|
</div>
|
||||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||||
Empty note
|
Empty note
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
|
|||||||
import { repo } from "../adapters";
|
import { repo } from "../adapters";
|
||||||
import { useNotesStore } from "../stores/notes";
|
import { useNotesStore } from "../stores/notes";
|
||||||
import { useConfigStore } from "../stores/config";
|
import { useConfigStore } from "../stores/config";
|
||||||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
|
||||||
import ColorPicker from "./ColorPicker.vue";
|
import ColorPicker from "./ColorPicker.vue";
|
||||||
import Icon from "./Icon.vue";
|
import Icon from "./Icon.vue";
|
||||||
import LabelPicker from "./LabelPicker.vue";
|
import LabelPicker from "./LabelPicker.vue";
|
||||||
@@ -27,7 +26,6 @@ const props = withDefaults(defineProps<{ note?: Note | null; initialBody?: strin
|
|||||||
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
const emit = defineEmits<{ (e: "close"): void; (e: "navigate", id: string): void }>();
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
const config = useConfigStore();
|
const config = useConfigStore();
|
||||||
const titles = useTitlesStore();
|
|
||||||
|
|
||||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||||
const title = ref(props.note?.title ?? "");
|
const title = ref(props.note?.title ?? "");
|
||||||
@@ -40,7 +38,6 @@ const root = ref<HTMLElement | null>(null);
|
|||||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||||
const fileInput = ref<HTMLInputElement | null>(null);
|
const fileInput = ref<HTMLInputElement | null>(null);
|
||||||
const uploadError = ref("");
|
const uploadError = ref("");
|
||||||
const backlinks = ref<{ id: string; title: string }[]>([]);
|
|
||||||
|
|
||||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||||
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
||||||
@@ -163,7 +160,6 @@ function resetCompose(): void {
|
|||||||
labelList.value = [];
|
labelList.value = [];
|
||||||
createKind.value = "text";
|
createKind.value = "text";
|
||||||
baseline.value = { title: null, body: "", color: "default" };
|
baseline.value = { title: null, body: "", color: "default" };
|
||||||
linkMenu.value = false;
|
|
||||||
uploadError.value = "";
|
uploadError.value = "";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -242,22 +238,7 @@ function onBackdropMousedown(): void {
|
|||||||
void dismiss();
|
void dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadBacklinks(): Promise<void> {
|
|
||||||
if (!noteId.value) {
|
|
||||||
backlinks.value = [];
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
backlinks.value = await repo.notes.backlinks(noteId.value);
|
|
||||||
} catch {
|
|
||||||
backlinks.value = [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
watch(() => noteId.value, loadBacklinks);
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
void titles.load();
|
|
||||||
void loadBacklinks();
|
|
||||||
await nextTick();
|
await nextTick();
|
||||||
const el = bodyInput.value;
|
const el = bodyInput.value;
|
||||||
el?.focus();
|
el?.focus();
|
||||||
@@ -265,99 +246,6 @@ onMounted(async () => {
|
|||||||
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
if (el) el.selectionStart = el.selectionEnd = el.value.length;
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- outgoing links (edit mode) ----
|
|
||||||
//
|
|
||||||
// Two sources, in order. The note's SAVED links carry the server's binding, so a
|
|
||||||
// target that has since been renamed still resolves and is listed under its current
|
|
||||||
// name. A link just typed into the textarea has no saved row yet, and the name index
|
|
||||||
// is the best that can be said about it until the note is saved.
|
|
||||||
const outgoingLinks = computed(() => {
|
|
||||||
const boundByNorm = new Map((props.note?.links ?? []).map((l) => [l.norm, l]));
|
|
||||||
const re = /\[\[([^[\]]+)\]\]/g;
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const out: { title: string; id: string | null }[] = [];
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = re.exec(body.value)) !== null) {
|
|
||||||
const t = match[1].trim();
|
|
||||||
const key = t.toLowerCase();
|
|
||||||
if (t && !seen.has(key)) {
|
|
||||||
seen.add(key);
|
|
||||||
const hit = boundByNorm.get(key);
|
|
||||||
out.push(hit ? { title: hit.title, id: hit.id } : { title: t, id: titles.resolve(t)?.id ?? null });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
async function openLink(link: { title: string; id: string | null }) {
|
|
||||||
if (link.id) {
|
|
||||||
emit("navigate", link.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const created = await notes.createTitled(link.title);
|
|
||||||
await titles.reload();
|
|
||||||
emit("navigate", created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- [[ link autocomplete in the body textarea ----
|
|
||||||
const linkMenu = ref(false);
|
|
||||||
const linkQuery = ref("");
|
|
||||||
const linkStart = ref(-1);
|
|
||||||
const linkSelected = ref(0);
|
|
||||||
const linkMatches = ref<TitleEntry[]>([]);
|
|
||||||
let linkTimer: ReturnType<typeof setTimeout> | undefined;
|
|
||||||
|
|
||||||
function refreshLinkMatches() {
|
|
||||||
if (linkTimer) clearTimeout(linkTimer);
|
|
||||||
const q = linkQuery.value.trim();
|
|
||||||
linkTimer = setTimeout(async () => {
|
|
||||||
try {
|
|
||||||
const results = await repo.notes.linkSearch(q);
|
|
||||||
linkMatches.value = results.filter((r) => r.id !== noteId.value).slice(0, 8);
|
|
||||||
} catch {
|
|
||||||
linkMatches.value = [];
|
|
||||||
}
|
|
||||||
linkSelected.value = 0;
|
|
||||||
}, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBodyInput() {
|
|
||||||
const el = bodyInput.value;
|
|
||||||
if (!el) return;
|
|
||||||
const caret = el.selectionStart ?? 0;
|
|
||||||
const text = body.value.slice(0, caret);
|
|
||||||
const open = text.lastIndexOf("[[");
|
|
||||||
if (open === -1) {
|
|
||||||
linkMenu.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const between = text.slice(open + 2);
|
|
||||||
if (between.includes("]") || between.includes("\n")) {
|
|
||||||
linkMenu.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
linkQuery.value = between;
|
|
||||||
linkStart.value = open;
|
|
||||||
linkSelected.value = 0;
|
|
||||||
linkMenu.value = true;
|
|
||||||
refreshLinkMatches();
|
|
||||||
}
|
|
||||||
|
|
||||||
function insertLink(t: string) {
|
|
||||||
const el = bodyInput.value;
|
|
||||||
const caret = el?.selectionStart ?? body.value.length;
|
|
||||||
const before = body.value.slice(0, linkStart.value);
|
|
||||||
const after = body.value.slice(caret);
|
|
||||||
const insertion = `[[${t}]]`;
|
|
||||||
body.value = before + insertion + after;
|
|
||||||
linkMenu.value = false;
|
|
||||||
const pos = before.length + insertion.length;
|
|
||||||
void nextTick(() => {
|
|
||||||
el?.focus();
|
|
||||||
el?.setSelectionRange(pos, pos);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBodyKeydown(e: KeyboardEvent) {
|
function onBodyKeydown(e: KeyboardEvent) {
|
||||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||||
@@ -365,25 +253,6 @@ function onBodyKeydown(e: KeyboardEvent) {
|
|||||||
void commitAndContinue();
|
void commitAndContinue();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!linkMenu.value || linkMatches.value.length === 0) return;
|
|
||||||
if (e.key === "ArrowDown") {
|
|
||||||
e.preventDefault();
|
|
||||||
linkSelected.value = Math.min(linkSelected.value + 1, linkMatches.value.length - 1);
|
|
||||||
} else if (e.key === "ArrowUp") {
|
|
||||||
e.preventDefault();
|
|
||||||
linkSelected.value = Math.max(linkSelected.value - 1, 0);
|
|
||||||
} else if (e.key === "Enter" || e.key === "Tab") {
|
|
||||||
const m = linkMatches.value[linkSelected.value];
|
|
||||||
if (m) {
|
|
||||||
e.preventDefault();
|
|
||||||
insertLink(m.title);
|
|
||||||
}
|
|
||||||
} else if (e.key === "Escape") {
|
|
||||||
// Close only the menu — don't let Esc bubble to the frame's close/commit.
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
linkMenu.value = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTitleEnter(e: KeyboardEvent) {
|
function onTitleEnter(e: KeyboardEvent) {
|
||||||
@@ -694,37 +563,15 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
@keydown.enter="onTitleEnter"
|
@keydown.enter="onTitleEnter"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="!showChecklist" class="relative">
|
<textarea
|
||||||
<textarea
|
v-if="!showChecklist"
|
||||||
ref="bodyInput"
|
ref="bodyInput"
|
||||||
v-model="body"
|
v-model="body"
|
||||||
rows="8"
|
rows="8"
|
||||||
:placeholder="bodyPlaceholder"
|
:placeholder="bodyPlaceholder"
|
||||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||||
@input="onBodyInput"
|
@keydown="onBodyKeydown"
|
||||||
@keydown="onBodyKeydown"
|
/>
|
||||||
/>
|
|
||||||
<ul
|
|
||||||
v-if="linkMenu && linkMatches.length"
|
|
||||||
class="absolute left-0 top-full z-10 mt-1 max-h-48 w-64 overflow-y-auto rounded-lg border border-neutral-200 bg-white p-1 shadow-lg dark:border-neutral-700 dark:bg-neutral-800"
|
|
||||||
>
|
|
||||||
<li v-for="(m, i) in linkMatches" :key="m.id">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex w-full items-center rounded-md px-2 py-1.5 text-left text-sm"
|
|
||||||
:class="
|
|
||||||
i === linkSelected
|
|
||||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
|
||||||
: 'hover:bg-neutral-100 dark:hover:bg-neutral-700'
|
|
||||||
"
|
|
||||||
@mousemove="linkSelected = i"
|
|
||||||
@mousedown.prevent="insertLink(m.title)"
|
|
||||||
>
|
|
||||||
<span class="truncate">{{ m.title }}</span>
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
<NoteChecklist v-else class="py-1" :note-id="liveNote.id" :items="liveNote.items" editable />
|
||||||
|
|
||||||
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
<div v-if="labelList.length" class="flex flex-wrap gap-1.5 pt-1">
|
||||||
@@ -793,43 +640,6 @@ function revPreview(rev: NoteRevision): string {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
|
||||||
v-if="!isCreate && (outgoingLinks.length || backlinks.length)"
|
|
||||||
class="flex flex-col gap-2 border-t border-neutral-100 pt-2 dark:border-neutral-800"
|
|
||||||
>
|
|
||||||
<div v-if="outgoingLinks.length">
|
|
||||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Links</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
v-for="link in outgoingLinks"
|
|
||||||
:key="link.title"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs"
|
|
||||||
:class="
|
|
||||||
link.id
|
|
||||||
? 'bg-brand/15 text-brand-700 dark:text-brand'
|
|
||||||
: 'bg-black/5 text-neutral-500 dark:bg-white/10 dark:text-neutral-400'
|
|
||||||
"
|
|
||||||
:title="link.id ? `Open ${link.title}` : `Create ${link.title}`"
|
|
||||||
@click="openLink(link)"
|
|
||||||
>
|
|
||||||
{{ link.title }}<span v-if="!link.id" class="opacity-60">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div v-if="backlinks.length">
|
|
||||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-neutral-400">Linked from</p>
|
|
||||||
<div class="flex flex-wrap gap-1.5">
|
|
||||||
<button
|
|
||||||
v-for="b in backlinks"
|
|
||||||
:key="b.id"
|
|
||||||
type="button"
|
|
||||||
class="inline-flex items-center rounded-full bg-black/5 px-2 py-0.5 text-xs text-neutral-600 hover:bg-black/10 dark:bg-white/10 dark:text-neutral-300"
|
|
||||||
@click="emit('navigate', b.id)"
|
|
||||||
>
|
|
||||||
{{ b.title }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
|
|||||||
//
|
//
|
||||||
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
|
// - `onClose` lets a host clean up its own state (e.g. a board's compose flag).
|
||||||
// - `list` is the host's local note array, tried first when resolving a navigated
|
// - `list` is the host's local note array, tried first when resolving a navigated
|
||||||
// [[wiki-link]] target before falling back to the store, then a fetch — so views
|
// note id before falling back to the store, then a fetch — so views
|
||||||
// that keep their own list (reminders, timeline, search, graph) still resolve
|
// that keep their own list (reminders, timeline, search) still resolve
|
||||||
// locally without duplicating the lookup.
|
// locally without duplicating the lookup.
|
||||||
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
|
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
|
||||||
const notes = useNotesStore();
|
const notes = useNotesStore();
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
// stays plain text; this only formats what's shown. We render the parsed tree as
|
// stays plain text; this only formats what's shown. We render the parsed tree as
|
||||||
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
|
// Vue vnodes (never v-html), so there is no HTML-injection surface. Deliberately a
|
||||||
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
|
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
|
||||||
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
|
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
|
||||||
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
|
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
|
||||||
// left as plain text and never mistaken for a heading.
|
// a heading.
|
||||||
|
|
||||||
export interface InlineToken {
|
export interface InlineToken {
|
||||||
type: "text" | "bold" | "italic" | "code" | "link";
|
type: "text" | "bold" | "italic" | "code";
|
||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -19,9 +19,13 @@ export interface Block {
|
|||||||
value?: string;
|
value?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Order matters: links + code are matched before emphasis so their contents aren't
|
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
|
||||||
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
|
// bold (**) before italic (*). Emphasis does not nest (v1).
|
||||||
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
//
|
||||||
|
// `[[wiki-links]]` used to lead this alternation. They are gone (note 2897) — this is
|
||||||
|
// a capture-and-recall surface, and a linking system is organization. `[[text]]` now
|
||||||
|
// renders as the literal characters someone typed, which is what it always was.
|
||||||
|
const INLINE_RE = /(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
||||||
|
|
||||||
export function parseInline(text: string): InlineToken[] {
|
export function parseInline(text: string): InlineToken[] {
|
||||||
const tokens: InlineToken[] = [];
|
const tokens: InlineToken[] = [];
|
||||||
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
|
|||||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||||
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
|
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
|
||||||
const raw = m[0];
|
const raw = m[0];
|
||||||
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
|
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||||
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||||
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
|
||||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||||
last = m.index + raw.length;
|
last = m.index + raw.length;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ const router = createRouter({
|
|||||||
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
{ path: "trash", name: "trash", component: () => import("../views/BoardView.vue") },
|
||||||
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
{ path: "label/:id", name: "label", component: () => import("../views/BoardView.vue") },
|
||||||
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
{ path: "search", name: "search", component: () => import("../views/SearchView.vue") },
|
||||||
{ path: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
|
||||||
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
||||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -63,27 +63,11 @@ export interface NoteRevision {
|
|||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// One resolved [[wiki-link]] out of a note: the normalized text as WRITTEN, and the
|
|
||||||
// note it actually points at with that note's name as it stands NOW.
|
|
||||||
//
|
|
||||||
// The server sends this because the client can no longer work it out. Resolution used
|
|
||||||
// to be a name lookup in the titles index, which only held together because renaming
|
|
||||||
// a note rewrote the link text inside every note that linked to it. Links are bound
|
|
||||||
// by id now and bodies are left alone, so the written text can name something the
|
|
||||||
// target is no longer called — and only the server holds the binding.
|
|
||||||
export interface NoteLinkRef {
|
|
||||||
/** The link text as written, trimmed and lowercased — the key a token matches on. */
|
|
||||||
norm: string;
|
|
||||||
id: string;
|
|
||||||
/** The target's CURRENT name, which is what gets rendered. */
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string | null;
|
title: string | null;
|
||||||
// The note's display NAME: explicit title, else its first body line (server-derived).
|
// The note's display NAME: explicit title, else its first body line (server-derived).
|
||||||
// Every note has one, so body-only notes are still nameable + [[link]]-able.
|
// Every note has one, so a body-only note still has something to be called.
|
||||||
display_title: string;
|
display_title: string;
|
||||||
body: string;
|
body: string;
|
||||||
color: NoteColor;
|
color: NoteColor;
|
||||||
@@ -101,11 +85,6 @@ export interface Note {
|
|||||||
items: ChecklistItem[];
|
items: ChecklistItem[];
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
previews: LinkPreview[];
|
previews: LinkPreview[];
|
||||||
// Absent offline: the desktop's local store derives links at query time and has no
|
|
||||||
// resolution to send. Rendering falls back to the titles index there, which is
|
|
||||||
// exactly right for a store where nothing else can have renamed the target behind
|
|
||||||
// this client's back.
|
|
||||||
links?: NoteLinkRef[];
|
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
}
|
}
|
||||||
@@ -245,12 +224,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createTitled(title: string): Promise<Note> {
|
|
||||||
const created = await repo.notes.createTitled(title);
|
|
||||||
reconcile(created);
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reorder(orderedIds: string[]): Promise<void> {
|
async function reorder(orderedIds: string[]): Promise<void> {
|
||||||
// Optimistically assign positions matching the backend (total - index), sort,
|
// Optimistically assign positions matching the backend (total - index), sort,
|
||||||
// then persist.
|
// then persist.
|
||||||
@@ -327,7 +300,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
deletePreview,
|
deletePreview,
|
||||||
importNotes,
|
importNotes,
|
||||||
fetchOne,
|
fetchOne,
|
||||||
createTitled,
|
|
||||||
reorder,
|
reorder,
|
||||||
trash,
|
trash,
|
||||||
restore,
|
restore,
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ export interface TitleEntry {
|
|||||||
title: string;
|
title: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Owner's {id,title} index, used to resolve [[wiki-links]] client-side.
|
// Owner's {id, name} index of every non-trashed note.
|
||||||
|
//
|
||||||
|
// Outlived [[wiki-links]] (note 2897), which is what it was originally built for,
|
||||||
|
// because the command palette lists it so someone can jump straight to a note by
|
||||||
|
// name. That is recall, which is what this app is for; `resolve()` went with the
|
||||||
|
// links.
|
||||||
export const useTitlesStore = defineStore("titles", () => {
|
export const useTitlesStore = defineStore("titles", () => {
|
||||||
const items = ref<TitleEntry[]>([]);
|
const items = ref<TitleEntry[]>([]);
|
||||||
const loaded = ref(false);
|
const loaded = ref(false);
|
||||||
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
|
|||||||
await load();
|
await load();
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolve(title: string): TitleEntry | null {
|
return { items, loaded, load, reload };
|
||||||
const norm = title.trim().toLowerCase();
|
|
||||||
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { items, loaded, load, reload, resolve };
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,420 +0,0 @@
|
|||||||
<script setup lang="ts">
|
|
||||||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
||||||
import { useRouter } from "vue-router";
|
|
||||||
import { api } from "../api/client";
|
|
||||||
import { NOTE_NODE_FILL, type NoteColor } from "../notes/colors";
|
|
||||||
import { useNoteEditor } from "../composables/useNoteEditor";
|
|
||||||
import AsyncState from "../components/AsyncState.vue";
|
|
||||||
import NoteEditor from "../components/NoteEditor.vue";
|
|
||||||
|
|
||||||
type NodeKind = "note" | "label";
|
|
||||||
|
|
||||||
interface GNode {
|
|
||||||
id: string;
|
|
||||||
title: string;
|
|
||||||
color: string;
|
|
||||||
kind: NodeKind;
|
|
||||||
labelId?: string;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
vx: number;
|
|
||||||
vy: number;
|
|
||||||
}
|
|
||||||
interface GEdge {
|
|
||||||
source: string;
|
|
||||||
target: string;
|
|
||||||
kind?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const WIDTH = 1000;
|
|
||||||
const HEIGHT = 700;
|
|
||||||
|
|
||||||
const router = useRouter();
|
|
||||||
const allNodes = ref<GNode[]>([]);
|
|
||||||
const edges = ref<GEdge[]>([]);
|
|
||||||
const loading = ref(true);
|
|
||||||
const error = ref("");
|
|
||||||
// Editor host: resolve a node id against the store, else fetch (shared controller).
|
|
||||||
const { editing, close: closeEditor, navigate } = useNoteEditor();
|
|
||||||
// Unlinked notes float in the space by default — the graph is a gentle overview,
|
|
||||||
// not a links-only surface. Label hubs are on by default so tags cluster notes.
|
|
||||||
const showAll = ref(true);
|
|
||||||
const showLabels = ref(true);
|
|
||||||
|
|
||||||
const svgRef = ref<SVGSVGElement | null>(null);
|
|
||||||
const gRef = ref<SVGGElement | null>(null);
|
|
||||||
|
|
||||||
// Pan/zoom applied to the inner <g>.
|
|
||||||
const scale = ref(1);
|
|
||||||
const tx = ref(0);
|
|
||||||
const ty = ref(0);
|
|
||||||
|
|
||||||
let frame = 0;
|
|
||||||
let raf = 0;
|
|
||||||
|
|
||||||
// Label membership edges drop out when the label hubs are hidden.
|
|
||||||
const visibleEdges = computed(() =>
|
|
||||||
showLabels.value ? edges.value : edges.value.filter((e) => e.kind !== "label"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const connectedIds = computed(() => {
|
|
||||||
const s = new Set<string>();
|
|
||||||
for (const e of visibleEdges.value) {
|
|
||||||
s.add(e.source);
|
|
||||||
s.add(e.target);
|
|
||||||
}
|
|
||||||
return s;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide label hubs when toggled off; otherwise show everything (unlinked notes
|
|
||||||
// float too) unless "show unlinked" is off, in which case keep only connected nodes.
|
|
||||||
const activeNodes = computed(() =>
|
|
||||||
allNodes.value.filter((n) => {
|
|
||||||
if (n.kind === "label" && !showLabels.value) return false;
|
|
||||||
if (showAll.value) return true;
|
|
||||||
return connectedIds.value.has(n.id);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const activeIds = computed(() => new Set(activeNodes.value.map((n) => n.id)));
|
|
||||||
|
|
||||||
const edgeLines = computed(() => {
|
|
||||||
const byId = new Map(allNodes.value.map((n) => [n.id, n]));
|
|
||||||
const out: { x1: number; y1: number; x2: number; y2: number; label: boolean }[] = [];
|
|
||||||
for (const e of visibleEdges.value) {
|
|
||||||
if (!activeIds.value.has(e.source) || !activeIds.value.has(e.target)) continue;
|
|
||||||
const s = byId.get(e.source);
|
|
||||||
const t = byId.get(e.target);
|
|
||||||
if (s && t) out.push({ x1: s.x, y1: s.y, x2: t.x, y2: t.y, label: e.kind === "label" });
|
|
||||||
}
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
|
|
||||||
function fill(color: string): string {
|
|
||||||
return NOTE_NODE_FILL[color as NoteColor] ?? NOTE_NODE_FILL.default;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadGraph() {
|
|
||||||
loading.value = true;
|
|
||||||
error.value = "";
|
|
||||||
try {
|
|
||||||
const res = await api.get<{
|
|
||||||
nodes: { id: string; title: string; color: string; kind: NodeKind; label_id?: string }[];
|
|
||||||
edges: GEdge[];
|
|
||||||
}>("/api/graph");
|
|
||||||
const cx = WIDTH / 2;
|
|
||||||
const cy = HEIGHT / 2;
|
|
||||||
const count = Math.max(res.nodes.length, 1);
|
|
||||||
allNodes.value = res.nodes.map((n, i) => {
|
|
||||||
const angle = (i / count) * Math.PI * 2;
|
|
||||||
return {
|
|
||||||
id: n.id,
|
|
||||||
title: n.title,
|
|
||||||
color: n.color,
|
|
||||||
kind: n.kind,
|
|
||||||
labelId: n.label_id,
|
|
||||||
x: cx + Math.cos(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
||||||
y: cy + Math.sin(angle) * 220 + (Math.random() - 0.5) * 40,
|
|
||||||
vx: 0,
|
|
||||||
vy: 0,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
edges.value = res.edges;
|
|
||||||
} catch (e) {
|
|
||||||
error.value = (e as { error?: string }).error ?? "Couldn't load the graph.";
|
|
||||||
allNodes.value = [];
|
|
||||||
edges.value = [];
|
|
||||||
} finally {
|
|
||||||
loading.value = false;
|
|
||||||
}
|
|
||||||
reheat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function reheat() {
|
|
||||||
frame = 0;
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
raf = 0;
|
|
||||||
if (activeNodes.value.length) simulate();
|
|
||||||
}
|
|
||||||
|
|
||||||
function simulate() {
|
|
||||||
const list = activeNodes.value;
|
|
||||||
const cx = WIDTH / 2;
|
|
||||||
const cy = HEIGHT / 2;
|
|
||||||
const byId = new Map(list.map((n) => [n.id, n]));
|
|
||||||
|
|
||||||
for (let i = 0; i < list.length; i++) {
|
|
||||||
for (let j = i + 1; j < list.length; j++) {
|
|
||||||
const a = list[i];
|
|
||||||
const b = list[j];
|
|
||||||
let dx = a.x - b.x;
|
|
||||||
let dy = a.y - b.y;
|
|
||||||
let d2 = dx * dx + dy * dy;
|
|
||||||
if (d2 < 0.01) {
|
|
||||||
d2 = 0.01;
|
|
||||||
dx = Math.random();
|
|
||||||
dy = Math.random();
|
|
||||||
}
|
|
||||||
const d = Math.sqrt(d2);
|
|
||||||
const force = 6000 / d2;
|
|
||||||
const fx = (dx / d) * force;
|
|
||||||
const fy = (dy / d) * force;
|
|
||||||
a.vx += fx;
|
|
||||||
a.vy += fy;
|
|
||||||
b.vx -= fx;
|
|
||||||
b.vy -= fy;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const e of visibleEdges.value) {
|
|
||||||
const s = byId.get(e.source);
|
|
||||||
const t = byId.get(e.target);
|
|
||||||
if (!s || !t) continue;
|
|
||||||
const dx = t.x - s.x;
|
|
||||||
const dy = t.y - s.y;
|
|
||||||
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
||||||
// Label-membership springs sit a touch longer so hubs ring their notes.
|
|
||||||
const rest = e.kind === "label" ? 150 : 130;
|
|
||||||
const diff = (d - rest) * 0.02;
|
|
||||||
const fx = (dx / d) * diff;
|
|
||||||
const fy = (dy / d) * diff;
|
|
||||||
s.vx += fx;
|
|
||||||
s.vy += fy;
|
|
||||||
t.vx -= fx;
|
|
||||||
t.vy -= fy;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const n of list) {
|
|
||||||
if (n === dragNode) {
|
|
||||||
n.vx = 0;
|
|
||||||
n.vy = 0;
|
|
||||||
continue; // pinned to the cursor while dragging
|
|
||||||
}
|
|
||||||
n.vx += (cx - n.x) * 0.002;
|
|
||||||
n.vy += (cy - n.y) * 0.002;
|
|
||||||
n.vx *= 0.85;
|
|
||||||
n.vy *= 0.85;
|
|
||||||
n.x += n.vx;
|
|
||||||
n.y += n.vy;
|
|
||||||
}
|
|
||||||
|
|
||||||
frame++;
|
|
||||||
// Keep running while cooling, or indefinitely while a node is being dragged.
|
|
||||||
raf = frame < 400 || dragNode ? requestAnimationFrame(simulate) : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- pointer interaction: drag a node, pan the background, wheel-zoom ---
|
|
||||||
let dragNode: GNode | null = null;
|
|
||||||
let dragMoved = false;
|
|
||||||
let downPos = { x: 0, y: 0 };
|
|
||||||
let panning = false;
|
|
||||||
let panLast = { x: 0, y: 0 };
|
|
||||||
|
|
||||||
function toLocal(el: SVGGraphicsElement | null, e: MouseEvent) {
|
|
||||||
const ctm = el?.getScreenCTM();
|
|
||||||
if (!ctm) return { x: 0, y: 0 };
|
|
||||||
const p = new DOMPoint(e.clientX, e.clientY).matrixTransform(ctm.inverse());
|
|
||||||
return { x: p.x, y: p.y };
|
|
||||||
}
|
|
||||||
|
|
||||||
function onNodeDown(n: GNode, e: MouseEvent) {
|
|
||||||
e.stopPropagation();
|
|
||||||
dragNode = n;
|
|
||||||
dragMoved = false;
|
|
||||||
downPos = { x: e.clientX, y: e.clientY };
|
|
||||||
reheat();
|
|
||||||
window.addEventListener("mousemove", onMove);
|
|
||||||
window.addEventListener("mouseup", onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBgDown(e: MouseEvent) {
|
|
||||||
panning = true;
|
|
||||||
panLast = toLocal(svgRef.value, e);
|
|
||||||
window.addEventListener("mousemove", onMove);
|
|
||||||
window.addEventListener("mouseup", onUp);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMove(e: MouseEvent) {
|
|
||||||
if (dragNode) {
|
|
||||||
if (Math.hypot(e.clientX - downPos.x, e.clientY - downPos.y) > 3) dragMoved = true;
|
|
||||||
const p = toLocal(gRef.value, e);
|
|
||||||
dragNode.x = p.x;
|
|
||||||
dragNode.y = p.y;
|
|
||||||
} else if (panning) {
|
|
||||||
const p = toLocal(svgRef.value, e);
|
|
||||||
tx.value += p.x - panLast.x;
|
|
||||||
ty.value += p.y - panLast.y;
|
|
||||||
panLast = p;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function onUp() {
|
|
||||||
window.removeEventListener("mousemove", onMove);
|
|
||||||
window.removeEventListener("mouseup", onUp);
|
|
||||||
const node = dragNode;
|
|
||||||
const moved = dragMoved;
|
|
||||||
dragNode = null;
|
|
||||||
panning = false;
|
|
||||||
// A press without a drag is a click.
|
|
||||||
if (node && !moved) clickNode(node);
|
|
||||||
}
|
|
||||||
|
|
||||||
function clickNode(n: GNode) {
|
|
||||||
// Label hub → jump to that label's board lens (one space, many lenses).
|
|
||||||
if (n.kind === "label" && n.labelId) {
|
|
||||||
void router.push(`/label/${n.labelId}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
void navigate(n.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
function onWheel(e: WheelEvent) {
|
|
||||||
e.preventDefault();
|
|
||||||
const vb = toLocal(svgRef.value, e);
|
|
||||||
const gx = (vb.x - tx.value) / scale.value;
|
|
||||||
const gy = (vb.y - ty.value) / scale.value;
|
|
||||||
const factor = e.deltaY < 0 ? 1.1 : 1 / 1.1;
|
|
||||||
scale.value = Math.min(Math.max(scale.value * factor, 0.3), 3);
|
|
||||||
tx.value = vb.x - gx * scale.value;
|
|
||||||
ty.value = vb.y - gy * scale.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resetView() {
|
|
||||||
scale.value = 1;
|
|
||||||
tx.value = 0;
|
|
||||||
ty.value = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleAll() {
|
|
||||||
showAll.value = !showAll.value;
|
|
||||||
reheat();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleLabels() {
|
|
||||||
showLabels.value = !showLabels.value;
|
|
||||||
reheat();
|
|
||||||
}
|
|
||||||
|
|
||||||
onMounted(loadGraph);
|
|
||||||
onBeforeUnmount(() => {
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
window.removeEventListener("mousemove", onMove);
|
|
||||||
window.removeEventListener("mouseup", onUp);
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="flex h-full flex-col p-4">
|
|
||||||
<!-- Titled by the shell's persistent lens name (task 1913). -->
|
|
||||||
<div class="mb-3 flex flex-wrap items-center justify-end gap-3">
|
|
||||||
<div class="flex items-center gap-3 text-sm">
|
|
||||||
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
|
||||||
<input type="checkbox" class="accent-brand" :checked="showLabels" @change="toggleLabels" />
|
|
||||||
Show labels
|
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center gap-1.5 text-neutral-600 dark:text-neutral-300">
|
|
||||||
<input type="checkbox" class="accent-brand" :checked="showAll" @change="toggleAll" />
|
|
||||||
Show unlinked notes
|
|
||||||
</label>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="rounded-md border border-neutral-300 px-2 py-1 text-xs hover:bg-neutral-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand dark:border-neutral-700 dark:hover:bg-neutral-800"
|
|
||||||
@click="resetView"
|
|
||||||
>
|
|
||||||
Reset view
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<AsyncState
|
|
||||||
:loading="loading"
|
|
||||||
:error="error || undefined"
|
|
||||||
error-title="Couldn't load the graph"
|
|
||||||
@retry="loadGraph"
|
|
||||||
>
|
|
||||||
<div v-if="allNodes.length === 0" class="py-24 text-center">
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">No notes yet</h2>
|
|
||||||
<p class="mt-1 text-sm text-neutral-400">
|
|
||||||
Create notes and link them with
|
|
||||||
<span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span> to see them here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else-if="activeNodes.length === 0" class="py-24 text-center">
|
|
||||||
<h2 class="text-lg font-semibold text-neutral-700 dark:text-neutral-200">Nothing connected yet</h2>
|
|
||||||
<p class="mt-1 text-sm text-neutral-400">
|
|
||||||
Link notes with <span class="font-mono text-brand-700 dark:text-brand">[[Note title]]</span>, add
|
|
||||||
<span class="font-mono text-brand-700 dark:text-brand">#tags</span>, or
|
|
||||||
<button type="button" class="text-brand-700 underline dark:text-brand" @click="toggleAll">
|
|
||||||
show all notes
|
|
||||||
</button>.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
v-else
|
|
||||||
class="min-h-[500px] flex-1 overflow-hidden rounded-xl border border-neutral-200 bg-neutral-50 dark:border-neutral-800 dark:bg-neutral-950"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
ref="svgRef"
|
|
||||||
:viewBox="`0 0 ${WIDTH} ${HEIGHT}`"
|
|
||||||
class="h-full w-full cursor-grab select-none touch-none"
|
|
||||||
preserveAspectRatio="xMidYMid meet"
|
|
||||||
@mousedown="onBgDown"
|
|
||||||
@wheel="onWheel"
|
|
||||||
>
|
|
||||||
<g ref="gRef" :transform="`translate(${tx} ${ty}) scale(${scale})`">
|
|
||||||
<line
|
|
||||||
v-for="(l, i) in edgeLines"
|
|
||||||
:key="`e${i}`"
|
|
||||||
:x1="l.x1"
|
|
||||||
:y1="l.y1"
|
|
||||||
:x2="l.x2"
|
|
||||||
:y2="l.y2"
|
|
||||||
class="stroke-neutral-300 dark:stroke-neutral-700"
|
|
||||||
:stroke-width="l.label ? 1 : 1.5"
|
|
||||||
:stroke-dasharray="l.label ? '3 3' : undefined"
|
|
||||||
/>
|
|
||||||
<g v-for="n in activeNodes" :key="n.id" class="cursor-pointer" @mousedown="onNodeDown(n, $event)">
|
|
||||||
<!-- Label hubs read as a larger ringed node so tags stand out from notes. -->
|
|
||||||
<circle
|
|
||||||
v-if="n.kind === 'label'"
|
|
||||||
:cx="n.x"
|
|
||||||
:cy="n.y"
|
|
||||||
r="12"
|
|
||||||
:fill="fill(n.color)"
|
|
||||||
fill-opacity="0.9"
|
|
||||||
class="stroke-neutral-50 dark:stroke-neutral-950"
|
|
||||||
stroke-width="3"
|
|
||||||
/>
|
|
||||||
<circle
|
|
||||||
v-else
|
|
||||||
:cx="n.x"
|
|
||||||
:cy="n.y"
|
|
||||||
r="8"
|
|
||||||
:fill="fill(n.color)"
|
|
||||||
class="stroke-neutral-50 dark:stroke-neutral-950"
|
|
||||||
stroke-width="1.5"
|
|
||||||
/>
|
|
||||||
<text
|
|
||||||
:x="n.x"
|
|
||||||
:y="n.kind === 'label' ? n.y - 17 : n.y - 13"
|
|
||||||
text-anchor="middle"
|
|
||||||
:class="
|
|
||||||
n.kind === 'label'
|
|
||||||
? 'fill-neutral-700 text-[12px] font-semibold dark:fill-neutral-100'
|
|
||||||
: 'fill-neutral-600 text-[12px] dark:fill-neutral-300'
|
|
||||||
"
|
|
||||||
>
|
|
||||||
{{ n.title }}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
</AsyncState>
|
|
||||||
|
|
||||||
<template v-if="editing">
|
|
||||||
<NoteEditor :note="editing" @close="closeEditor" @navigate="navigate" />
|
|
||||||
</template>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -15,7 +15,6 @@ from .auth import bp as auth_bp
|
|||||||
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
from .client_dist import advertisement as client_advertisement, bp as client_bp
|
||||||
from .config import Config
|
from .config import Config
|
||||||
from .db import session_scope
|
from .db import session_scope
|
||||||
from .graph import bp as graph_bp
|
|
||||||
from .labels import bp as labels_bp
|
from .labels import bp as labels_bp
|
||||||
from .notes import bp as notes_bp
|
from .notes import bp as notes_bp
|
||||||
from .retention import run_sweeper
|
from .retention import run_sweeper
|
||||||
@@ -78,7 +77,6 @@ def create_app() -> Quart:
|
|||||||
app.register_blueprint(auth_bp)
|
app.register_blueprint(auth_bp)
|
||||||
app.register_blueprint(notes_bp)
|
app.register_blueprint(notes_bp)
|
||||||
app.register_blueprint(labels_bp)
|
app.register_blueprint(labels_bp)
|
||||||
app.register_blueprint(graph_bp)
|
|
||||||
app.register_blueprint(settings_bp)
|
app.register_blueprint(settings_bp)
|
||||||
app.register_blueprint(sync_bp)
|
app.register_blueprint(sync_bp)
|
||||||
app.register_blueprint(saved_filters_bp)
|
app.register_blueprint(saved_filters_bp)
|
||||||
|
|||||||
@@ -1,117 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from quart import Blueprint, g, jsonify
|
|
||||||
from sqlalchemy import and_, func, or_, select
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
from .auth import login_required
|
|
||||||
from .db import session_scope
|
|
||||||
from .models.label import Label, NoteLabel
|
|
||||||
from .models.note import Note
|
|
||||||
from .models.note_link import NoteLink
|
|
||||||
|
|
||||||
bp = Blueprint("graph", __name__, url_prefix="/api/graph")
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("")
|
|
||||||
@login_required
|
|
||||||
async def get_graph():
|
|
||||||
"""Spatial view of the owner's non-trashed notes.
|
|
||||||
|
|
||||||
Nodes are two kinds:
|
|
||||||
- notes (kind="note") — every non-trashed note; each carries its first
|
|
||||||
label's color for tinting.
|
|
||||||
- labels (kind="label", id "label:<uuid>") — every label actually attached
|
|
||||||
to a live note, acting as a clustering HUB so tagged notes gravitate
|
|
||||||
together even without wiki-links between them.
|
|
||||||
Edges are two kinds:
|
|
||||||
- wiki-links (kind="link") — resolved [[links]] (note_links.target_norm
|
|
||||||
matched to a note's normalized display_title).
|
|
||||||
- membership (kind="label") — each note → each of its label hubs.
|
|
||||||
The frontend toggles labels + unlinked notes; the graph is a light auxiliary
|
|
||||||
lens, not a focal surface.
|
|
||||||
"""
|
|
||||||
source = aliased(Note)
|
|
||||||
target = aliased(Note)
|
|
||||||
# A link joins on the note it was BOUND to, and only falls back to matching by
|
|
||||||
# name where it was never bound — a forward link written before its target
|
|
||||||
# existed. Name-matching alone is what used to make a rename break the graph.
|
|
||||||
edge_stmt = (
|
|
||||||
select(source.id, target.id)
|
|
||||||
.select_from(NoteLink)
|
|
||||||
.join(source, source.id == NoteLink.source_id)
|
|
||||||
.join(
|
|
||||||
target,
|
|
||||||
or_(
|
|
||||||
target.id == NoteLink.target_id,
|
|
||||||
and_(
|
|
||||||
NoteLink.target_id.is_(None),
|
|
||||||
func.lower(func.trim(target.display_title)) == NoteLink.target_norm,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
source.owner_id == g.user_id,
|
|
||||||
source.deleted_at.is_(None),
|
|
||||||
target.owner_id == g.user_id,
|
|
||||||
target.deleted_at.is_(None),
|
|
||||||
source.id != target.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
async with session_scope() as db:
|
|
||||||
rows = (await db.execute(edge_stmt)).all()
|
|
||||||
edges = []
|
|
||||||
seen: set = set()
|
|
||||||
for src_id, tgt_id in rows:
|
|
||||||
key = (src_id, tgt_id)
|
|
||||||
if key in seen:
|
|
||||||
continue
|
|
||||||
seen.add(key)
|
|
||||||
edges.append({"source": str(src_id), "target": str(tgt_id), "kind": "link"})
|
|
||||||
|
|
||||||
# Note ↔ label membership: one row per (note, label) for the owner's
|
|
||||||
# non-trashed notes. Drives both the note-color tint (first label by name)
|
|
||||||
# and the label-hub nodes + membership edges.
|
|
||||||
label_rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(NoteLabel.note_id, Label.id, Label.name, Label.color)
|
|
||||||
.join(Label, Label.id == NoteLabel.label_id)
|
|
||||||
.join(Note, Note.id == NoteLabel.note_id)
|
|
||||||
.where(
|
|
||||||
Label.owner_id == g.user_id,
|
|
||||||
Note.owner_id == g.user_id,
|
|
||||||
Note.deleted_at.is_(None),
|
|
||||||
)
|
|
||||||
.order_by(Label.name)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
|
|
||||||
first_color: dict = {}
|
|
||||||
label_nodes: dict = {}
|
|
||||||
for note_id, label_id, label_name, label_color in label_rows:
|
|
||||||
first_color.setdefault(note_id, label_color)
|
|
||||||
hub_id = f"label:{label_id}"
|
|
||||||
if hub_id not in label_nodes:
|
|
||||||
label_nodes[hub_id] = {
|
|
||||||
"id": hub_id,
|
|
||||||
"title": f"#{label_name}",
|
|
||||||
"color": label_color or "default",
|
|
||||||
"kind": "label",
|
|
||||||
"label_id": str(label_id),
|
|
||||||
}
|
|
||||||
edges.append({"source": str(note_id), "target": hub_id, "kind": "label"})
|
|
||||||
|
|
||||||
note_rows = (
|
|
||||||
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
|
||||||
).all()
|
|
||||||
nodes = [
|
|
||||||
{
|
|
||||||
"id": str(n.id),
|
|
||||||
"title": n.display_title or "Untitled",
|
|
||||||
"color": first_color.get(n.id, "default"),
|
|
||||||
"kind": "note",
|
|
||||||
}
|
|
||||||
for n in note_rows
|
|
||||||
]
|
|
||||||
nodes.extend(label_nodes.values())
|
|
||||||
return jsonify({"nodes": nodes, "edges": edges})
|
|
||||||
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
|
|||||||
note,
|
note,
|
||||||
note_attachment,
|
note_attachment,
|
||||||
note_item,
|
note_item,
|
||||||
note_link,
|
|
||||||
note_link_preview,
|
note_link_preview,
|
||||||
note_revision,
|
note_revision,
|
||||||
saved_filter,
|
saved_filter,
|
||||||
|
|||||||
@@ -40,9 +40,9 @@ class Note(Base):
|
|||||||
)
|
)
|
||||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||||
# The note's display NAME: explicit title if set, else the first non-empty body
|
# The note's display NAME: explicit title if set, else the first non-empty body
|
||||||
# line (see notes.derive_display_title). Persisted + normalized-matched so every
|
# line (see notes.derive_display_title). Persisted so every note — even a body-only
|
||||||
# note — even a body-only one — is nameable, searchable, graphable, and
|
# one — has something to be called in search results and in an export filename,
|
||||||
# [[wiki-link]]-able without forcing the user to type a title.
|
# without forcing the user to type a title.
|
||||||
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
display_title: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
body: Mapped[str] = mapped_column(Text(), nullable=False, server_default="")
|
||||||
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
color: Mapped[str] = mapped_column(Text(), nullable=False, server_default="default")
|
||||||
|
|||||||
@@ -1,47 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
from sqlalchemy import ForeignKey, Index, Text
|
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from . import Base
|
|
||||||
|
|
||||||
|
|
||||||
class NoteLink(Base):
|
|
||||||
"""A [[wiki-link]] from a source note to another note.
|
|
||||||
|
|
||||||
Two target columns, and the pair is the point:
|
|
||||||
|
|
||||||
- ``target_id`` — the note this link actually points at, bound when the link was
|
|
||||||
written. This is what makes a link survive its target being RENAMED. A note's
|
|
||||||
name is derived from its first body line, so without an id the name is the
|
|
||||||
edge, and editing that line would silently break every inbound link (or, in the
|
|
||||||
older design, force a rewrite of every linking note's body).
|
|
||||||
- ``target_norm`` — the normalized link text, always stored. It is what an
|
|
||||||
UNRESOLVED link carries: `[[a note that doesn't exist yet]]` is a supported way
|
|
||||||
to create one, so a link has to be able to name a target that isn't there.
|
|
||||||
|
|
||||||
Resolution reads the id first and falls back to matching the norm against
|
|
||||||
``notes.display_title``, which is how a forward link connects the moment its
|
|
||||||
target appears. ``_claim_unresolved_links`` then binds the id, so the fallback is
|
|
||||||
a transitional state rather than a permanent one.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = "note_links"
|
|
||||||
__table_args__ = (
|
|
||||||
Index("ix_note_links_target", "target_norm"),
|
|
||||||
Index("ix_note_links_target_id", "target_id"),
|
|
||||||
)
|
|
||||||
|
|
||||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
|
||||||
source_id: Mapped[uuid.UUID] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="CASCADE"), nullable=False
|
|
||||||
)
|
|
||||||
# SET NULL rather than CASCADE: deleting the target un-resolves the link, it does
|
|
||||||
# not delete it. The link text is still sitting in the source's body.
|
|
||||||
target_id: Mapped[uuid.UUID | None] = mapped_column(
|
|
||||||
UUID(as_uuid=True), ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
|
||||||
)
|
|
||||||
target_norm: Mapped[str] = mapped_column(Text(), nullable=False)
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Notes API (the `/api/notes` blueprint).
|
"""Notes API (the `/api/notes` blueprint).
|
||||||
|
|
||||||
The bulk of the shared logic lives in cohesive sibling modules — serialization
|
The bulk of the shared logic lives in cohesive sibling modules — serialization
|
||||||
(`serialize`), wiki-links/tags (`links`), recurring reminders (`recurrence`),
|
(`serialize`), #tags (`tags`), recurring reminders (`recurrence`),
|
||||||
small text/query helpers (`helpers`), and export/import (`import_export`). The
|
small text/query helpers (`helpers`), and export/import (`import_export`). The
|
||||||
route handlers themselves stay here so blueprint registration is in one place, and
|
route handlers themselves stay here so blueprint registration is in one place, and
|
||||||
`bp` is defined in `_bp` so every module can import it without a cycle.
|
`bp` is defined in `_bp` so every module can import it without a cycle.
|
||||||
@@ -19,7 +19,7 @@ import zipfile
|
|||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
from quart import Response, g, jsonify, request, send_file
|
from quart import Response, g, jsonify, request, send_file
|
||||||
from sqlalchemy import and_, case, func, literal_column, or_, select
|
from sqlalchemy import func, literal_column, select
|
||||||
|
|
||||||
from ..acl import visible_to_user
|
from ..acl import visible_to_user
|
||||||
from ..auth import login_required
|
from ..auth import login_required
|
||||||
@@ -32,7 +32,6 @@ from ..models.label import Label, NoteLabel
|
|||||||
from ..models.note import Note
|
from ..models.note import Note
|
||||||
from ..models.note_attachment import NoteAttachment
|
from ..models.note_attachment import NoteAttachment
|
||||||
from ..models.note_item import NoteItem
|
from ..models.note_item import NoteItem
|
||||||
from ..models.note_link import NoteLink
|
|
||||||
from ..models.note_link_preview import NoteLinkPreview
|
from ..models.note_link_preview import NoteLinkPreview
|
||||||
from ..models.note_revision import NoteRevision
|
from ..models.note_revision import NoteRevision
|
||||||
from ..responses import json_error, not_found, parse_uuid
|
from ..responses import json_error, not_found, parse_uuid
|
||||||
@@ -44,7 +43,6 @@ from .helpers import (
|
|||||||
ALLOWED_IMAGE_MIMES,
|
ALLOWED_IMAGE_MIMES,
|
||||||
VALID_FILTERS,
|
VALID_FILTERS,
|
||||||
_attachment_ext,
|
_attachment_ext,
|
||||||
_escape_like,
|
|
||||||
_get_owned,
|
_get_owned,
|
||||||
_header_filename,
|
_header_filename,
|
||||||
_safe_filename,
|
_safe_filename,
|
||||||
@@ -65,11 +63,8 @@ from .import_export import (
|
|||||||
_read_import_specs,
|
_read_import_specs,
|
||||||
_usec_to_dt,
|
_usec_to_dt,
|
||||||
)
|
)
|
||||||
from .links import (
|
from .tags import (
|
||||||
_reconcile_tags,
|
_reconcile_tags,
|
||||||
_claim_unresolved_links,
|
|
||||||
_rewrite_links,
|
|
||||||
parse_link_titles,
|
|
||||||
parse_tags,
|
parse_tags,
|
||||||
)
|
)
|
||||||
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
|
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
|
||||||
@@ -81,15 +76,11 @@ __all__ = [
|
|||||||
"is_empty_note",
|
"is_empty_note",
|
||||||
"parse_list_items",
|
"parse_list_items",
|
||||||
"parse_tags",
|
"parse_tags",
|
||||||
"parse_link_titles",
|
|
||||||
"normalize_color",
|
"normalize_color",
|
||||||
"normalize_recurrence",
|
"normalize_recurrence",
|
||||||
"next_occurrence",
|
"next_occurrence",
|
||||||
"_reconcile_tags",
|
"_reconcile_tags",
|
||||||
"_claim_unresolved_links",
|
|
||||||
"_rewrite_links",
|
|
||||||
"_serialize_notes",
|
"_serialize_notes",
|
||||||
"_escape_like",
|
|
||||||
"_safe_filename",
|
"_safe_filename",
|
||||||
"_attachment_ext",
|
"_attachment_ext",
|
||||||
"_header_filename",
|
"_header_filename",
|
||||||
@@ -378,9 +369,13 @@ async def import_notes():
|
|||||||
@bp.get("/titles")
|
@bp.get("/titles")
|
||||||
@login_required
|
@login_required
|
||||||
async def list_titles():
|
async def list_titles():
|
||||||
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
|
"""Owner's non-trashed notes, keyed by their display NAME.
|
||||||
# first body line) — the index the frontend uses to resolve + autocomplete
|
|
||||||
# [[wiki-links]]. Every note has a name now, so body-only notes are linkable too.
|
Survived the removal of [[wiki-links]] (note 2897) because it was serving two
|
||||||
|
different things, and only one of them was linking. This is what the command
|
||||||
|
palette lists so someone can jump to a note by name — which is recall, the thing
|
||||||
|
this app is actually for. The `[[` autocomplete that also read it is gone.
|
||||||
|
"""
|
||||||
async with session_scope() as db:
|
async with session_scope() as db:
|
||||||
rows = (
|
rows = (
|
||||||
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
await db.scalars(select(Note).where(Note.owner_id == g.user_id, Note.deleted_at.is_(None)))
|
||||||
@@ -390,78 +385,6 @@ async def list_titles():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/link-search")
|
|
||||||
@login_required
|
|
||||||
async def link_search():
|
|
||||||
# Autocomplete source for [[wiki-links]]: match the query against a note's display
|
|
||||||
# NAME *or* its BODY, so you can link by recalling any phrase — not just the name.
|
|
||||||
# Substring ILIKE (good for partial-word typing, deterministic, fine at personal
|
|
||||||
# scale; the FTS index still powers the heavier /search). Name matches rank above
|
|
||||||
# body-only matches, and a name prefix above a mid-name substring. Empty q → recent.
|
|
||||||
q = (request.args.get("q") or "").strip()
|
|
||||||
async with session_scope() as db:
|
|
||||||
base = select(Note).where(
|
|
||||||
Note.owner_id == g.user_id, Note.deleted_at.is_(None), Note.display_title != ""
|
|
||||||
)
|
|
||||||
if not q:
|
|
||||||
stmt = base.order_by(Note.updated_at.desc()).limit(10)
|
|
||||||
else:
|
|
||||||
esc = _escape_like(q)
|
|
||||||
name_hit = Note.display_title.ilike(f"%{esc}%", escape="\\")
|
|
||||||
stmt = (
|
|
||||||
base.where(name_hit | Note.body.ilike(f"%{esc}%", escape="\\"))
|
|
||||||
.order_by(
|
|
||||||
case((name_hit, 0), else_=1),
|
|
||||||
case((Note.display_title.ilike(f"{esc}%", escape="\\"), 0), else_=1),
|
|
||||||
Note.updated_at.desc(),
|
|
||||||
)
|
|
||||||
.limit(10)
|
|
||||||
)
|
|
||||||
rows = (await db.scalars(stmt)).all()
|
|
||||||
return jsonify({"results": [{"id": str(n.id), "title": n.display_title} for n in rows]})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/<note_id>/backlinks")
|
|
||||||
@login_required
|
|
||||||
async def note_backlinks(note_id: str):
|
|
||||||
nid = parse_uuid(note_id)
|
|
||||||
if nid is None:
|
|
||||||
return not_found()
|
|
||||||
async with session_scope() as db:
|
|
||||||
note = await db.scalar(
|
|
||||||
select(Note).where(Note.id == nid, visible_to_user("note", Note.owner_id, Note.id, g.user_id))
|
|
||||||
)
|
|
||||||
if note is None:
|
|
||||||
return not_found()
|
|
||||||
# Bound links (target_id) OR unbound ones still naming this note. The second
|
|
||||||
# half is what catches a link written before this note existed and not yet
|
|
||||||
# claimed; without it a forward link would go quiet until its source is next
|
|
||||||
# saved.
|
|
||||||
norm = (note.display_title or "").strip().lower()
|
|
||||||
matches = NoteLink.target_id == nid
|
|
||||||
if norm:
|
|
||||||
matches = or_(matches, and_(NoteLink.target_id.is_(None), NoteLink.target_norm == norm))
|
|
||||||
sources = (
|
|
||||||
await db.scalars(
|
|
||||||
select(Note)
|
|
||||||
.join(NoteLink, NoteLink.source_id == Note.id)
|
|
||||||
.where(
|
|
||||||
matches,
|
|
||||||
Note.owner_id == g.user_id,
|
|
||||||
Note.deleted_at.is_(None),
|
|
||||||
Note.id != nid,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
seen: set = set()
|
|
||||||
out = []
|
|
||||||
for n in sources:
|
|
||||||
if n.id not in seen:
|
|
||||||
seen.add(n.id)
|
|
||||||
out.append({"id": str(n.id), "title": n.display_title})
|
|
||||||
return jsonify({"backlinks": out})
|
|
||||||
|
|
||||||
|
|
||||||
@bp.post("/reorder")
|
@bp.post("/reorder")
|
||||||
@login_required
|
@login_required
|
||||||
async def reorder_notes():
|
async def reorder_notes():
|
||||||
@@ -525,11 +448,7 @@ async def create_note():
|
|||||||
await db.flush() # assign note.id before writing items/links
|
await db.flush() # assign note.id before writing items/links
|
||||||
for pos, text in enumerate(item_texts):
|
for pos, text in enumerate(item_texts):
|
||||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||||
await _rewrite_links(db, note)
|
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
# A new note may be exactly what earlier `[[links]]` were pointing at — the
|
|
||||||
# create-by-linking flow writes the link first and the note second.
|
|
||||||
await _claim_unresolved_links(db, note)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note)), 201
|
return jsonify(await _serialize_note(db, note)), 201
|
||||||
@@ -558,7 +477,6 @@ async def update_note(note_id: str):
|
|||||||
note = await _get_owned(db, note_id)
|
note = await _get_owned(db, note_id)
|
||||||
if note is None:
|
if note is None:
|
||||||
return not_found()
|
return not_found()
|
||||||
old_display = note.display_title
|
|
||||||
old_title = note.title
|
old_title = note.title
|
||||||
old_body = note.body
|
old_body = note.body
|
||||||
if "title" in data:
|
if "title" in data:
|
||||||
@@ -591,14 +509,7 @@ async def update_note(note_id: str):
|
|||||||
if "title" in data or "body" in data:
|
if "title" in data or "body" in data:
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
note.display_title = derive_display_title(note.title, note.body)
|
||||||
if "body" in data:
|
if "body" in data:
|
||||||
await _rewrite_links(db, note)
|
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
# A rename no longer touches anything else's TEXT. Inbound links already hold
|
|
||||||
# this note's id, so they follow it automatically; all that is left is to
|
|
||||||
# adopt any still-unresolved link that was waiting for this name.
|
|
||||||
new_display = note.display_title
|
|
||||||
if old_display != new_display:
|
|
||||||
await _claim_unresolved_links(db, note)
|
|
||||||
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
# Version history: snapshot the PRE-edit title+body whenever either changed.
|
||||||
if note.title != old_title or note.body != old_body:
|
if note.title != old_title or note.body != old_body:
|
||||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||||
@@ -651,16 +562,11 @@ async def restore_revision(note_id: str, rev_id: str):
|
|||||||
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
return jsonify(await _serialize_note(db, note)) # already at this version — no-op
|
||||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||||
# the revision — with the same title/body ripple as a normal edit.
|
# the revision — with the same title/body ripple as a normal edit.
|
||||||
old_display = note.display_title
|
|
||||||
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
|
db.add(NoteRevision(note_id=note.id, title=note.title, body=note.body))
|
||||||
note.title = rev.title
|
note.title = rev.title
|
||||||
note.body = rev.body
|
note.body = rev.body
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
note.display_title = derive_display_title(note.title, note.body)
|
||||||
await _rewrite_links(db, note)
|
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
new_display = note.display_title
|
|
||||||
if old_display != new_display:
|
|
||||||
await _claim_unresolved_links(db, note)
|
|
||||||
await db.commit()
|
await db.commit()
|
||||||
await db.refresh(note)
|
await db.refresh(note)
|
||||||
return jsonify(await _serialize_note(db, note))
|
return jsonify(await _serialize_note(db, note))
|
||||||
|
|||||||
@@ -74,11 +74,6 @@ async def _get_owned(db, note_id: str) -> Note | None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _escape_like(s: str) -> str:
|
|
||||||
"""Escape LIKE wildcards so user input matches literally (escape char = \\)."""
|
|
||||||
return s.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
|
||||||
|
|
||||||
|
|
||||||
def _slugify(text: str) -> str:
|
def _slugify(text: str) -> str:
|
||||||
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
||||||
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ from .helpers import (
|
|||||||
derive_display_title,
|
derive_display_title,
|
||||||
is_empty_note,
|
is_empty_note,
|
||||||
)
|
)
|
||||||
from .links import _find_or_create_label, _reconcile_tags, _rewrite_links
|
from .tags import _find_or_create_label, _reconcile_tags
|
||||||
from .recurrence import normalize_recurrence
|
from .recurrence import normalize_recurrence
|
||||||
|
|
||||||
|
|
||||||
@@ -333,6 +333,5 @@ async def _create_imported_note(
|
|||||||
if isinstance(att, dict):
|
if isinstance(att, dict):
|
||||||
_import_attachment(db, note, zf, att, budget)
|
_import_attachment(db, note, zf, att, budget)
|
||||||
|
|
||||||
await _rewrite_links(db, note)
|
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,146 +0,0 @@
|
|||||||
"""[[wiki-links]] and #tags — parsing note bodies and keeping the derived
|
|
||||||
note_links / tag-sourced note_labels rows in sync with the text. Manual (picker)
|
|
||||||
labels are NOT touched here (see the labeling module)."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import re
|
|
||||||
|
|
||||||
from sqlalchemy import delete, func, select, update
|
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
from ..models.label import Label, NoteLabel
|
|
||||||
from ..models.note import Note
|
|
||||||
from ..models.note_link import NoteLink
|
|
||||||
|
|
||||||
_LINK_RE = re.compile(r"\[\[([^\[\]]+)\]\]")
|
|
||||||
|
|
||||||
# A #tag: `#` at the start of the body or after whitespace, then a word char and
|
|
||||||
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
|
|
||||||
# whitespace, so it won't match.
|
|
||||||
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_tags(body: str | None) -> list[str]:
|
|
||||||
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
|
|
||||||
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
|
|
||||||
if not body:
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
seen: set[str] = set()
|
|
||||||
for match in _TAG_RE.finditer(body):
|
|
||||||
tag = match.group(1)
|
|
||||||
if not any(c.isalpha() for c in tag):
|
|
||||||
continue
|
|
||||||
norm = tag.lower()
|
|
||||||
if norm not in seen:
|
|
||||||
seen.add(norm)
|
|
||||||
out.append(tag)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
def parse_link_titles(body: str | None) -> list[str]:
|
|
||||||
"""Extract distinct normalized [[wiki-link]] titles from a note body."""
|
|
||||||
if not body:
|
|
||||||
return []
|
|
||||||
out: list[str] = []
|
|
||||||
for match in _LINK_RE.finditer(body):
|
|
||||||
norm = match.group(1).strip().lower()
|
|
||||||
if norm and norm not in out:
|
|
||||||
out.append(norm)
|
|
||||||
return out
|
|
||||||
|
|
||||||
|
|
||||||
async def _resolve_target(db, owner_id, norm: str, exclude_id=None):
|
|
||||||
"""The owner's note currently NAMED `norm`, or None.
|
|
||||||
|
|
||||||
Owner-scoped so a link can never bind to someone else's note, and self-excluded
|
|
||||||
so a note that opens with its own name doesn't link to itself.
|
|
||||||
"""
|
|
||||||
stmt = select(Note.id).where(
|
|
||||||
Note.owner_id == owner_id,
|
|
||||||
Note.deleted_at.is_(None),
|
|
||||||
func.lower(func.trim(Note.display_title)) == norm,
|
|
||||||
)
|
|
||||||
if exclude_id is not None:
|
|
||||||
stmt = stmt.where(Note.id != exclude_id)
|
|
||||||
return await db.scalar(stmt)
|
|
||||||
|
|
||||||
|
|
||||||
async def _rewrite_links(db, note: Note) -> None:
|
|
||||||
"""Replace a note's outgoing wiki-links from its current body.
|
|
||||||
|
|
||||||
Each link is bound to the target's ID where one exists under that name right now.
|
|
||||||
That binding is what survives the target being renamed later; the norm is kept
|
|
||||||
either way, so a link to a note that doesn't exist yet is still recorded and can
|
|
||||||
resolve when it does.
|
|
||||||
"""
|
|
||||||
await db.execute(delete(NoteLink).where(NoteLink.source_id == note.id))
|
|
||||||
for norm in parse_link_titles(note.body):
|
|
||||||
target_id = await _resolve_target(db, note.owner_id, norm, exclude_id=note.id)
|
|
||||||
db.add(NoteLink(source_id=note.id, target_norm=norm, target_id=target_id))
|
|
||||||
|
|
||||||
|
|
||||||
async def _claim_unresolved_links(db, note: Note) -> None:
|
|
||||||
"""Bind still-unresolved links that name this note to it.
|
|
||||||
|
|
||||||
Called when a note's display name changes or a note is created. Two cases, one
|
|
||||||
mechanism: someone wrote `[[groceries]]` before any note was called that, or a
|
|
||||||
note has just been renamed INTO a name that other notes were already pointing at.
|
|
||||||
|
|
||||||
This is what replaced `_rename_inbound_links`, and the difference is the whole
|
|
||||||
point of the change: that function edited the BODIES of other people's notes to
|
|
||||||
keep their link text matching. This touches only link rows. A note's text is
|
|
||||||
never modified by something happening to a different note.
|
|
||||||
"""
|
|
||||||
norm = (note.display_title or "").strip().lower()
|
|
||||||
if not norm:
|
|
||||||
return
|
|
||||||
source = aliased(Note)
|
|
||||||
unresolved = (
|
|
||||||
select(NoteLink.id)
|
|
||||||
.join(source, source.id == NoteLink.source_id)
|
|
||||||
.where(
|
|
||||||
NoteLink.target_id.is_(None),
|
|
||||||
NoteLink.target_norm == norm,
|
|
||||||
source.owner_id == note.owner_id,
|
|
||||||
source.id != note.id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
await db.execute(
|
|
||||||
update(NoteLink).where(NoteLink.id.in_(unresolved.scalar_subquery())).values(target_id=note.id)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _find_or_create_label(db, owner_id, name: str):
|
|
||||||
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
|
|
||||||
existing = await db.scalar(
|
|
||||||
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
|
|
||||||
)
|
|
||||||
if existing is not None:
|
|
||||||
return existing
|
|
||||||
label = Label(owner_id=owner_id, name=name)
|
|
||||||
db.add(label)
|
|
||||||
await db.flush()
|
|
||||||
return label.id
|
|
||||||
|
|
||||||
|
|
||||||
async def _reconcile_tags(db, note: Note) -> None:
|
|
||||||
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
|
|
||||||
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
|
|
||||||
picker labels (via_tag=False) are never touched."""
|
|
||||||
tag_label_ids: set = set()
|
|
||||||
for name in parse_tags(note.body):
|
|
||||||
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
|
|
||||||
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
|
|
||||||
attached_ids = {r.label_id for r in rows}
|
|
||||||
# Detach tag-labels no longer backed by a #tag in the body.
|
|
||||||
for r in rows:
|
|
||||||
if r.via_tag and r.label_id not in tag_label_ids:
|
|
||||||
await db.delete(r)
|
|
||||||
attached_ids.discard(r.label_id)
|
|
||||||
# Attach new tags — skip labels already attached (in any form) to respect the PK
|
|
||||||
# and leave a manually-added label of the same name as-is.
|
|
||||||
for lid in tag_label_ids:
|
|
||||||
if lid not in attached_ids:
|
|
||||||
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
|
|
||||||
attached_ids.add(lid)
|
|
||||||
@@ -1,16 +1,14 @@
|
|||||||
"""Note serialization — turn a Note (+ its labels/items/attachments/previews/links)
|
"""Note serialization — turn a Note (+ its labels/items/attachments/previews) into
|
||||||
into the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
|
the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
|
||||||
collection for a batch of notes in one query, so list endpoints avoid N+1s."""
|
collection for a batch of notes in one query, so list endpoints avoid N+1s."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from sqlalchemy import and_, func, or_, select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import aliased
|
|
||||||
|
|
||||||
from ..models.label import Label, NoteLabel
|
from ..models.label import Label, NoteLabel
|
||||||
from ..models.note import Note
|
from ..models.note import Note
|
||||||
from ..models.note_attachment import NoteAttachment
|
from ..models.note_attachment import NoteAttachment
|
||||||
from ..models.note_item import NoteItem
|
from ..models.note_item import NoteItem
|
||||||
from ..models.note_link import NoteLink
|
|
||||||
from ..models.note_link_preview import NoteLinkPreview
|
from ..models.note_link_preview import NoteLinkPreview
|
||||||
|
|
||||||
|
|
||||||
@@ -106,64 +104,6 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
async def _links_for_notes(db, note_ids: list) -> dict:
|
|
||||||
"""Map note_id -> [{norm, id, title}] for each note's RESOLVED outgoing links.
|
|
||||||
|
|
||||||
The client cannot work this out for itself any more, and that is deliberate. It
|
|
||||||
used to resolve `[[text]]` by looking the text up in a client-side name index,
|
|
||||||
which only worked because a rename rewrote the text in every linking note. Now
|
|
||||||
that a link is bound to an id and the text is left alone, the stored text can name
|
|
||||||
something the target is no longer called — so the server, which holds the binding,
|
|
||||||
is the only place that knows where a link goes.
|
|
||||||
|
|
||||||
`title` is the target's name RIGHT NOW, so a renamed note reads correctly
|
|
||||||
everywhere it is linked from without a single body having been edited.
|
|
||||||
|
|
||||||
Unresolved links are simply absent: the client renders those as the
|
|
||||||
create-on-click affordance it already has.
|
|
||||||
"""
|
|
||||||
if not note_ids:
|
|
||||||
return {}
|
|
||||||
source = aliased(Note)
|
|
||||||
target = aliased(Note)
|
|
||||||
rows = (
|
|
||||||
await db.execute(
|
|
||||||
select(NoteLink.source_id, NoteLink.target_norm, target.id, target.display_title)
|
|
||||||
.select_from(NoteLink)
|
|
||||||
.join(source, source.id == NoteLink.source_id)
|
|
||||||
.join(
|
|
||||||
target,
|
|
||||||
or_(
|
|
||||||
target.id == NoteLink.target_id,
|
|
||||||
and_(
|
|
||||||
NoteLink.target_id.is_(None),
|
|
||||||
func.lower(func.trim(target.display_title)) == NoteLink.target_norm,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.where(
|
|
||||||
NoteLink.source_id.in_(note_ids),
|
|
||||||
target.deleted_at.is_(None),
|
|
||||||
# Owner-scoped, and NOT optional. A bound target_id was resolved
|
|
||||||
# owner-scoped when it was written, but the name fallback matches on
|
|
||||||
# display_title alone — without this, two users who both have a note
|
|
||||||
# called "Groceries" would leak each other's note id and name through
|
|
||||||
# an unresolved link. (Rule 47.)
|
|
||||||
target.owner_id == source.owner_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).all()
|
|
||||||
result: dict = {}
|
|
||||||
for source_id, norm, target_id, title in rows:
|
|
||||||
bucket = result.setdefault(source_id, [])
|
|
||||||
# The name-fallback join can produce more than one candidate for the same
|
|
||||||
# text; first one wins, deterministically enough for a display hint.
|
|
||||||
if any(link["norm"] == norm for link in bucket):
|
|
||||||
continue
|
|
||||||
bucket.append({"norm": norm, "id": str(target_id), "title": title})
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
async def _serialize_note(db, note: Note) -> dict:
|
async def _serialize_note(db, note: Note) -> dict:
|
||||||
data = note.serialize()
|
data = note.serialize()
|
||||||
labels = await _labels_for_notes(db, [note.id])
|
labels = await _labels_for_notes(db, [note.id])
|
||||||
@@ -174,8 +114,6 @@ async def _serialize_note(db, note: Note) -> dict:
|
|||||||
data["attachments"] = attachments.get(note.id, [])
|
data["attachments"] = attachments.get(note.id, [])
|
||||||
previews = await _previews_for_notes(db, [note.id])
|
previews = await _previews_for_notes(db, [note.id])
|
||||||
data["previews"] = previews.get(note.id, [])
|
data["previews"] = previews.get(note.id, [])
|
||||||
links = await _links_for_notes(db, [note.id])
|
|
||||||
data["links"] = links.get(note.id, [])
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
@@ -185,7 +123,6 @@ async def _serialize_notes(db, notes: list) -> list:
|
|||||||
items_map = await _items_for_notes(db, ids)
|
items_map = await _items_for_notes(db, ids)
|
||||||
attach_map = await _attachments_for_notes(db, ids)
|
attach_map = await _attachments_for_notes(db, ids)
|
||||||
preview_map = await _previews_for_notes(db, ids)
|
preview_map = await _previews_for_notes(db, ids)
|
||||||
link_map = await _links_for_notes(db, ids)
|
|
||||||
out = []
|
out = []
|
||||||
for n in notes:
|
for n in notes:
|
||||||
data = n.serialize()
|
data = n.serialize()
|
||||||
@@ -193,6 +130,5 @@ async def _serialize_notes(db, notes: list) -> list:
|
|||||||
data["items"] = items_map.get(n.id, [])
|
data["items"] = items_map.get(n.id, [])
|
||||||
data["attachments"] = attach_map.get(n.id, [])
|
data["attachments"] = attach_map.get(n.id, [])
|
||||||
data["previews"] = preview_map.get(n.id, [])
|
data["previews"] = preview_map.get(n.id, [])
|
||||||
data["links"] = link_map.get(n.id, [])
|
|
||||||
out.append(data)
|
out.append(data)
|
||||||
return out
|
return out
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""#tags — parsing note bodies and keeping the derived tag-sourced note_labels rows
|
||||||
|
in sync with the text. Manual (picker) labels are NOT touched here (see the labeling
|
||||||
|
module).
|
||||||
|
|
||||||
|
Was `links.py`, and also owned `[[wiki-links]]` until they were removed (note 2897):
|
||||||
|
this app is an intermediary surface for capture and recall, and a linking system is
|
||||||
|
organization, which is not what it is for. A file called links.py holding no links
|
||||||
|
would have been exactly the kind of drift that removal was meant to end.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
from sqlalchemy import func, select
|
||||||
|
|
||||||
|
from ..models.label import Label, NoteLabel
|
||||||
|
from ..models.note import Note
|
||||||
|
|
||||||
|
# A #tag: `#` at the start of the body or after whitespace, then a word char and
|
||||||
|
# word chars/hyphens. A URL fragment (foo#bar) or mid-word `#` is not preceded by
|
||||||
|
# whitespace, so it won't match.
|
||||||
|
_TAG_RE = re.compile(r"(?:^|(?<=\s))#(\w[\w-]*)")
|
||||||
|
|
||||||
|
|
||||||
|
def parse_tags(body: str | None) -> list[str]:
|
||||||
|
"""Distinct #hashtags from a note body, in order, deduped case-insensitively.
|
||||||
|
A tag must contain a letter, so #2024 or #_ are ignored (avoids numeric noise)."""
|
||||||
|
if not body:
|
||||||
|
return []
|
||||||
|
out: list[str] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for match in _TAG_RE.finditer(body):
|
||||||
|
tag = match.group(1)
|
||||||
|
if not any(c.isalpha() for c in tag):
|
||||||
|
continue
|
||||||
|
norm = tag.lower()
|
||||||
|
if norm not in seen:
|
||||||
|
seen.add(norm)
|
||||||
|
out.append(tag)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
async def _find_or_create_label(db, owner_id, name: str):
|
||||||
|
"""Owner's label id for `name` (case-insensitive match), creating it if absent."""
|
||||||
|
existing = await db.scalar(
|
||||||
|
select(Label.id).where(Label.owner_id == owner_id, func.lower(Label.name) == name.lower())
|
||||||
|
)
|
||||||
|
if existing is not None:
|
||||||
|
return existing
|
||||||
|
label = Label(owner_id=owner_id, name=name)
|
||||||
|
db.add(label)
|
||||||
|
await db.flush()
|
||||||
|
return label.id
|
||||||
|
|
||||||
|
|
||||||
|
async def _reconcile_tags(db, note: Note) -> None:
|
||||||
|
"""Sync tag-sourced labels (via_tag=True) with the #hashtags in the note body:
|
||||||
|
attach labels for current tags, detach tag-labels whose #tag was removed. Manual
|
||||||
|
picker labels (via_tag=False) are never touched."""
|
||||||
|
tag_label_ids: set = set()
|
||||||
|
for name in parse_tags(note.body):
|
||||||
|
tag_label_ids.add(await _find_or_create_label(db, note.owner_id, name))
|
||||||
|
rows = (await db.scalars(select(NoteLabel).where(NoteLabel.note_id == note.id))).all()
|
||||||
|
attached_ids = {r.label_id for r in rows}
|
||||||
|
# Detach tag-labels no longer backed by a #tag in the body.
|
||||||
|
for r in rows:
|
||||||
|
if r.via_tag and r.label_id not in tag_label_ids:
|
||||||
|
await db.delete(r)
|
||||||
|
attached_ids.discard(r.label_id)
|
||||||
|
# Attach new tags — skip labels already attached (in any form) to respect the PK
|
||||||
|
# and leave a manually-added label of the same name as-is.
|
||||||
|
for lid in tag_label_ids:
|
||||||
|
if lid not in attached_ids:
|
||||||
|
db.add(NoteLabel(note_id=note.id, label_id=lid, via_tag=True))
|
||||||
|
attached_ids.add(lid)
|
||||||
@@ -33,7 +33,6 @@ from .models.label import NoteLabel
|
|||||||
from .models.note import Note
|
from .models.note import Note
|
||||||
from .models.note_attachment import NoteAttachment
|
from .models.note_attachment import NoteAttachment
|
||||||
from .models.note_item import NoteItem
|
from .models.note_item import NoteItem
|
||||||
from .models.note_link import NoteLink
|
|
||||||
from .models.note_link_preview import NoteLinkPreview
|
from .models.note_link_preview import NoteLinkPreview
|
||||||
from .models.note_revision import NoteRevision
|
from .models.note_revision import NoteRevision
|
||||||
from .settings import get_setting
|
from .settings import get_setting
|
||||||
@@ -88,7 +87,6 @@ async def purge_note(db, note: Note, edited_at: datetime | None = None) -> None:
|
|||||||
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
|
await db.execute(sa_delete(NoteAttachment).where(NoteAttachment.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
|
await db.execute(sa_delete(NoteItem).where(NoteItem.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
await db.execute(sa_delete(NoteLabel).where(NoteLabel.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteLink).where(NoteLink.source_id == note.id))
|
|
||||||
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
await db.execute(sa_delete(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id))
|
||||||
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
await db.execute(sa_delete(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||||
note.title = None
|
note.title = None
|
||||||
|
|||||||
+1
-10
@@ -28,8 +28,6 @@ from .models.note_item import NoteItem
|
|||||||
from .models.note_revision import NoteRevision
|
from .models.note_revision import NoteRevision
|
||||||
from .notes import (
|
from .notes import (
|
||||||
_reconcile_tags,
|
_reconcile_tags,
|
||||||
_claim_unresolved_links,
|
|
||||||
_rewrite_links,
|
|
||||||
_serialize_notes,
|
_serialize_notes,
|
||||||
derive_display_title,
|
derive_display_title,
|
||||||
normalize_color,
|
normalize_color,
|
||||||
@@ -282,7 +280,7 @@ async def _apply_note(db, ch: dict) -> dict:
|
|||||||
elif note.purged_at is not None:
|
elif note.purged_at is not None:
|
||||||
note.purged_at = None # client re-created/edited → clear the tombstone
|
note.purged_at = None # client re-created/edited → clear the tombstone
|
||||||
|
|
||||||
old_title, old_body, old_display = note.title, note.body, note.display_title
|
old_title, old_body = note.title, note.body
|
||||||
_assign_note_fields(note, ch)
|
_assign_note_fields(note, ch)
|
||||||
note.display_title = derive_display_title(note.title, note.body)
|
note.display_title = derive_display_title(note.title, note.body)
|
||||||
if edited_at is not None:
|
if edited_at is not None:
|
||||||
@@ -292,15 +290,8 @@ async def _apply_note(db, ch: dict) -> dict:
|
|||||||
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
db.add(NoteRevision(note_id=note.id, title=old_title, body=old_body))
|
||||||
await db.flush() # assign note.id before items/labels/links
|
await db.flush() # assign note.id before items/labels/links
|
||||||
await _apply_note_items(db, note, ch)
|
await _apply_note_items(db, note, ch)
|
||||||
await _rewrite_links(db, note)
|
|
||||||
await _reconcile_tags(db, note)
|
await _reconcile_tags(db, note)
|
||||||
await _apply_note_manual_labels(db, note, ch)
|
await _apply_note_manual_labels(db, note, ch)
|
||||||
# Any change to the name — including a note arriving for the first time, where
|
|
||||||
# the old name was empty — may be what unresolved inbound links were waiting for.
|
|
||||||
# Nothing else's body is touched; see _claim_unresolved_links.
|
|
||||||
new_display = note.display_title
|
|
||||||
if old_display != new_display:
|
|
||||||
await _claim_unresolved_links(db, note)
|
|
||||||
await db.flush()
|
await db.flush()
|
||||||
await db.refresh(note, ["sync_revision"])
|
await db.refresh(note, ["sync_revision"])
|
||||||
return {
|
return {
|
||||||
|
|||||||
+6
-49
@@ -7,7 +7,6 @@ from thoughtsync.common import coerce_bool, parse_dt
|
|||||||
from thoughtsync.models.note import NOTE_COLORS, Note
|
from thoughtsync.models.note import NOTE_COLORS, Note
|
||||||
from thoughtsync.notes import (
|
from thoughtsync.notes import (
|
||||||
_attachment_ext,
|
_attachment_ext,
|
||||||
_escape_like,
|
|
||||||
_header_filename,
|
_header_filename,
|
||||||
_keep_spec,
|
_keep_spec,
|
||||||
_native_spec,
|
_native_spec,
|
||||||
@@ -19,7 +18,6 @@ from thoughtsync.notes import (
|
|||||||
next_occurrence,
|
next_occurrence,
|
||||||
normalize_color,
|
normalize_color,
|
||||||
normalize_recurrence,
|
normalize_recurrence,
|
||||||
parse_link_titles,
|
|
||||||
parse_list_items,
|
parse_list_items,
|
||||||
parse_tags,
|
parse_tags,
|
||||||
)
|
)
|
||||||
@@ -40,7 +38,7 @@ def test_all_note_routes_registered(app):
|
|||||||
for name in (
|
for name in (
|
||||||
"list_notes", "search_notes", "list_reminders", "complete_reminder",
|
"list_notes", "search_notes", "list_reminders", "complete_reminder",
|
||||||
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
||||||
"link_search", "note_backlinks", "reorder_notes", "create_note",
|
"reorder_notes", "create_note",
|
||||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||||
"set_note_labels", "add_item", "update_item", "delete_item",
|
"set_note_labels", "add_item", "update_item", "delete_item",
|
||||||
"reorder_items", "upload_attachment", "get_attachment",
|
"reorder_items", "upload_attachment", "get_attachment",
|
||||||
@@ -117,32 +115,11 @@ async def test_reorder_requires_auth(app):
|
|||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
def test_parse_link_titles():
|
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
|
||||||
titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again")
|
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
|
||||||
assert titles == ["alpha", "beta"]
|
# links alive across a rename, and the id-binding that briefly replaced them. Nothing
|
||||||
|
# here asserts their absence — `test_all_note_routes_registered` below is what would
|
||||||
|
# notice a route coming back, and the removal is one commit rather than a fossil.
|
||||||
def test_parse_link_titles_empty():
|
|
||||||
assert parse_link_titles(None) == []
|
|
||||||
assert parse_link_titles("no links here") == []
|
|
||||||
|
|
||||||
|
|
||||||
# `rewrite_link_title` and `_rename_inbound_links` are gone (M13 step 1). They kept
|
|
||||||
# backlinks alive across a rename by editing the [[text]] inside every note that
|
|
||||||
# linked to the renamed one — which meant one note's edit silently rewrote another's
|
|
||||||
# words. Links are bound to a note id now, so a rename needs no repair at all.
|
|
||||||
#
|
|
||||||
# What replaced them (`_resolve_target`, `_claim_unresolved_links`, and the id-first
|
|
||||||
# resolution in backlinks / the graph / serialization) is all SQL, and this suite runs
|
|
||||||
# without a database, so it is deliberately not asserted here. See the task log: that
|
|
||||||
# behaviour was checked by hand, and this repo has no integration lane to hold it.
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_link_titles_ignores_nesting():
|
|
||||||
# The link regex refuses [ and ] inside a token, so a malformed nest yields the
|
|
||||||
# inner name rather than something spanning both — worth pinning, since this is
|
|
||||||
# the string that becomes a link's stored target.
|
|
||||||
assert parse_link_titles("[[outer [[inner]] ]]") == ["inner"]
|
|
||||||
|
|
||||||
|
|
||||||
def test_derive_display_title_explicit_wins():
|
def test_derive_display_title_explicit_wins():
|
||||||
@@ -188,14 +165,6 @@ def test_parse_list_items():
|
|||||||
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
|
assert parse_list_items([1, "x", None, {"a": 1}]) == ["x"]
|
||||||
|
|
||||||
|
|
||||||
def test_escape_like():
|
|
||||||
# LIKE wildcards in user input must be neutralized so they match literally.
|
|
||||||
assert _escape_like("100%") == "100\\%"
|
|
||||||
assert _escape_like("a_b") == "a\\_b"
|
|
||||||
assert _escape_like("c:\\path") == "c:\\\\path"
|
|
||||||
assert _escape_like("plain") == "plain"
|
|
||||||
|
|
||||||
|
|
||||||
def test_parse_dt():
|
def test_parse_dt():
|
||||||
# A full ISO instant round-trips (used to validate the Timeline date range).
|
# A full ISO instant round-trips (used to validate the Timeline date range).
|
||||||
d = parse_dt("2026-07-19T12:30:00+00:00")
|
d = parse_dt("2026-07-19T12:30:00+00:00")
|
||||||
@@ -217,18 +186,6 @@ async def test_titles_requires_auth(app):
|
|||||||
assert resp.status_code == 401
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
async def test_link_search_requires_auth(app):
|
|
||||||
client = app.test_client()
|
|
||||||
resp = await client.get("/api/notes/link-search?q=hi")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_graph_requires_auth(app):
|
|
||||||
client = app.test_client()
|
|
||||||
resp = await client.get("/api/graph")
|
|
||||||
assert resp.status_code == 401
|
|
||||||
|
|
||||||
|
|
||||||
async def test_reminders_requires_auth(app):
|
async def test_reminders_requires_auth(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.get("/api/notes/reminders")
|
resp = await client.get("/api/notes/reminders")
|
||||||
|
|||||||
Reference in New Issue
Block a user