0.2.0 — a notebook in your pocket, ready to be hosted #3
@@ -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
|
||||
//! what the server computes on save. Pure string scanning (no regex dependency),
|
||||
//! kept in lockstep with the frontend's inline rules (see frontend notes/markdown.ts):
|
||||
//! Deriving `#tags` from a note's body — the local mirror of what the server computes
|
||||
//! on save. Pure string scanning (no regex dependency), kept in lockstep with the
|
||||
//! 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).
|
||||
//! On save these become labels attached with `via_tag = true`.
|
||||
//!
|
||||
//! Both dedupe case-insensitively, preserving first-seen order.
|
||||
|
||||
/// Extract the trimmed inner text of every `[[wiki-link]]` in `body`.
|
||||
pub fn extract_links(body: &str) -> Vec<String> {
|
||||
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
|
||||
}
|
||||
//! Dedupes case-insensitively, preserving first-seen order.
|
||||
//!
|
||||
//! Also derived `[[wiki-links]]` until they were removed (note 2897) — this is a
|
||||
//! capture-and-recall surface, and a linking system is organization.
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
@@ -73,28 +49,6 @@ fn push_unique(out: &mut Vec<String>, candidate: &str) {
|
||||
mod tests {
|
||||
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]
|
||||
fn tags_basic() {
|
||||
assert_eq!(
|
||||
@@ -116,7 +70,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
assert!(extract_links("").is_empty());
|
||||
assert!(extract_tags("").is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ pub struct Note {
|
||||
pub id: String,
|
||||
pub title: Option<String>,
|
||||
/// 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 body: String,
|
||||
pub color: String,
|
||||
@@ -94,12 +94,6 @@ pub struct TitleEntry {
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Backlink {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SavedFilter {
|
||||
pub id: String,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! 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
|
||||
//! `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.
|
||||
|
||||
|
||||
@@ -350,61 +350,6 @@ pub fn search(conn: &Connection, q: &str) -> rusqlite::Result<Vec<Note>> {
|
||||
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 -----------------------------------------------------------
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
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> {
|
||||
let obj = changes
|
||||
.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())
|
||||
}
|
||||
|
||||
#[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]
|
||||
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())?;
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
#[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]
|
||||
pub fn labels_list(db: State<'_, Db>) -> Result<Vec<Label>, 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_get,
|
||||
commands::local::notes_create,
|
||||
commands::local::notes_create_titled,
|
||||
commands::local::notes_update,
|
||||
commands::local::notes_complete_reminder,
|
||||
commands::local::notes_snooze_reminder,
|
||||
@@ -121,8 +120,6 @@ pub fn run() {
|
||||
commands::local::notes_reminders,
|
||||
commands::local::notes_titles,
|
||||
commands::local::notes_search,
|
||||
commands::local::notes_backlinks,
|
||||
commands::local::notes_link_search,
|
||||
commands::local::labels_list,
|
||||
commands::local::labels_create,
|
||||
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.
|
||||
- **Label** — the label *catalog* (name + color) syncs as its own entity so a
|
||||
rename/recolor/delete propagates independently of notes.
|
||||
- **Derived, NOT synced:** `[[wiki-links]]` and `#tags` are parsed from the note
|
||||
body. Clients recompute them locally; the server recomputes them on push. They
|
||||
never travel over the wire.
|
||||
- **Derived, NOT synced:** `#tags` are parsed from the note body. Clients recompute
|
||||
them locally; the server recomputes them on push. They 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
|
||||
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
|
||||
state (not a partial patch) — the server overwrites all scalar fields, replaces
|
||||
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
|
||||
`trashed: true`.
|
||||
- **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 { User } from "../stores/session";
|
||||
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.";
|
||||
|
||||
@@ -51,7 +51,6 @@ export const local: Repo = {
|
||||
list: (query) => invoke<Note[]>("notes_list", { query }),
|
||||
get: (id) => invoke<Note>("notes_get", { id }),
|
||||
create: (input) => invoke<Note>("notes_create", { input }),
|
||||
createTitled: (title) => invoke<Note>("notes_create_titled", { title }),
|
||||
update: (id, changes) => invoke<Note>("notes_update", { id, changes }),
|
||||
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||
@@ -73,8 +72,6 @@ export const local: Repo = {
|
||||
reminders: () => invoke<Note[]>("notes_reminders"),
|
||||
titles: () => invoke<TitleEntry[]>("notes_titles"),
|
||||
search: (q) => invoke<Note[]>("notes_search", { q }),
|
||||
backlinks: (id) => invoke<Backlink[]>("notes_backlinks", { id }),
|
||||
linkSearch: (q) => invoke<TitleEntry[]>("notes_link_search", { q }),
|
||||
},
|
||||
|
||||
savedFilters: {
|
||||
|
||||
@@ -49,10 +49,6 @@ export interface ChecklistItemChanges {
|
||||
checked?: boolean;
|
||||
}
|
||||
|
||||
export interface Backlink {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
source: string;
|
||||
@@ -97,7 +93,6 @@ export interface NotesRepo {
|
||||
list(query: NoteListQuery): Promise<Note[]>;
|
||||
get(id: string): Promise<Note>;
|
||||
create(input: NoteCreateInput): Promise<Note>;
|
||||
createTitled(title: string): Promise<Note>;
|
||||
update(id: string, changes: NoteChanges): Promise<Note>;
|
||||
completeReminder(id: string): Promise<Note>;
|
||||
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
||||
@@ -119,8 +114,6 @@ export interface NotesRepo {
|
||||
reminders(): Promise<Note[]>;
|
||||
titles(): Promise<TitleEntry[]>;
|
||||
search(q: string): Promise<Note[]>;
|
||||
backlinks(id: string): Promise<Backlink[]>;
|
||||
linkSearch(q: string): Promise<TitleEntry[]>;
|
||||
}
|
||||
|
||||
export interface SavedFiltersRepo {
|
||||
|
||||
@@ -13,7 +13,6 @@ import type { TitleEntry } from "../stores/titles";
|
||||
import type { User } from "../stores/session";
|
||||
import type { PublicConfig } from "../stores/config";
|
||||
import type {
|
||||
Backlink,
|
||||
DeviceToken,
|
||||
ImportResult,
|
||||
NoteChanges,
|
||||
@@ -80,7 +79,6 @@ export const rest: Repo = {
|
||||
list: async (query) => (await api.get<{ notes: Note[] }>(`/api/notes?${notesQuery(query)}`)).notes,
|
||||
get: (id) => api.get<Note>(`/api/notes/${id}`),
|
||||
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),
|
||||
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
||||
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,
|
||||
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,
|
||||
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: {
|
||||
|
||||
@@ -49,7 +49,6 @@ const shortcuts = [
|
||||
{ label: "New note (or just start typing)", keys: ["Enter", "c"] },
|
||||
{ label: "Search", keys: ["/"] },
|
||||
{ label: "Go to Board", keys: ["g", "b"] },
|
||||
{ label: "Go to Graph", keys: ["g", "g"] },
|
||||
{ label: "Go to Reminders", keys: ["g", "r"] },
|
||||
{ label: "Go to Timeline", keys: ["g", "t"] },
|
||||
{ label: "Browse cards", keys: ["↑", "↓", "←", "→"] },
|
||||
@@ -121,11 +120,6 @@ function onKeydown(e: KeyboardEvent) {
|
||||
void router.push("/");
|
||||
return;
|
||||
}
|
||||
if (e.key === "g") {
|
||||
e.preventDefault();
|
||||
void router.push("/graph");
|
||||
return;
|
||||
}
|
||||
if (e.key === "r") {
|
||||
e.preventDefault();
|
||||
void router.push("/reminders");
|
||||
@@ -207,7 +201,7 @@ watch(
|
||||
*
|
||||
* 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)
|
||||
* 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
|
||||
* looking for; "Label" would tell them nothing they didn't already know.
|
||||
@@ -224,8 +218,6 @@ const lensName = computed<string>(() => {
|
||||
return "Timeline";
|
||||
case "reminders":
|
||||
return "Reminders";
|
||||
case "graph":
|
||||
return "Graph";
|
||||
case "label":
|
||||
// The store may not have loaded yet on a deep link; fall back rather than
|
||||
// flashing an empty slot.
|
||||
@@ -403,9 +395,6 @@ async function signOut() {
|
||||
<RouterLink to="/" class="nav-link" :class="route.name === 'board' ? 'nav-link-active' : ''">
|
||||
<Icon name="note" /> Notes
|
||||
</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">
|
||||
<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
|
||||
and refetching, which is precisely the page-change feeling this is meant
|
||||
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. -->
|
||||
<main id="main" tabindex="-1" class="min-w-0 flex-1 focus:outline-none">
|
||||
<RouterView v-slot="{ Component }">
|
||||
|
||||
@@ -42,7 +42,6 @@ const commands = computed<Row[]>(() => {
|
||||
const list: Row[] = [
|
||||
{ id: "cmd:new", label: "New note", hint: "Action", run: compose },
|
||||
{ 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:timeline", label: "Go to Timeline", hint: "Navigate", run: () => go("/timeline") },
|
||||
{ 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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
|
||||
@@ -1,65 +1,17 @@
|
||||
<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";
|
||||
|
||||
const props = defineProps<{ tokens: InlineToken[]; links?: NoteLinkRef[] }>();
|
||||
|
||||
const router = useRouter();
|
||||
const notes = useNotesStore();
|
||||
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 } });
|
||||
}
|
||||
// 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
|
||||
// of that.
|
||||
defineProps<{ tokens: InlineToken[] }>();
|
||||
</script>
|
||||
|
||||
<!-- Rendered tightly (no whitespace between tokens) so a token's own leading/trailing
|
||||
spaces are preserved and no extra spaces are introduced. -->
|
||||
<template
|
||||
><template v-for="(t, i) in tokens" :key="i"
|
||||
><span
|
||||
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
|
||||
><strong v-if="t.type === 'bold'" class="font-semibold">{{ t.value }}</strong
|
||||
><em v-else-if="t.type === 'italic'">{{ t.value }}</em
|
||||
><code
|
||||
v-else-if="t.type === 'code'"
|
||||
|
||||
@@ -2,38 +2,35 @@
|
||||
import { computed } from "vue";
|
||||
import { parseMarkdown } from "../notes/markdown";
|
||||
import MarkdownInline from "./MarkdownInline.vue";
|
||||
import type { NoteLinkRef } from "../stores/notes";
|
||||
|
||||
// `links` is the owning note's resolved [[links]], passed straight through to every
|
||||
// inline run — only MarkdownInline uses it, but only this component knows the note.
|
||||
const props = defineProps<{ text: string; links?: NoteLinkRef[] }>();
|
||||
const props = defineProps<{ text: string }>();
|
||||
const blocks = computed(() => parseMarkdown(props.text));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-1.5 break-words">
|
||||
<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>
|
||||
<h4 v-else-if="b.type === 'h2'" class="text-sm font-bold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" :links="links" /></h5>
|
||||
<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 ?? []" /></h4>
|
||||
<h5 v-else-if="b.type === 'h3'" class="text-sm font-semibold"><MarkdownInline :tokens="b.inline ?? []" /></h5>
|
||||
<blockquote
|
||||
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"
|
||||
>
|
||||
<MarkdownInline :tokens="b.inline ?? []" :links="links" />
|
||||
<MarkdownInline :tokens="b.inline ?? []" />
|
||||
</blockquote>
|
||||
<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>
|
||||
<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>
|
||||
<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"
|
||||
>{{ 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -237,7 +237,7 @@ onBeforeUnmount(() => document.removeEventListener("mousedown", onDocMousedown))
|
||||
{{ note.title }}
|
||||
</h3>
|
||||
<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>
|
||||
<p v-if="!note.title && !note.body && !note.attachments.length" class="text-sm italic text-neutral-400">
|
||||
Empty note
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, nextTick, onMounted, ref, watch } from "vue";
|
||||
import { repo } from "../adapters";
|
||||
import { useNotesStore } from "../stores/notes";
|
||||
import { useConfigStore } from "../stores/config";
|
||||
import { useTitlesStore, type TitleEntry } from "../stores/titles";
|
||||
import ColorPicker from "./ColorPicker.vue";
|
||||
import Icon from "./Icon.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 notes = useNotesStore();
|
||||
const config = useConfigStore();
|
||||
const titles = useTitlesStore();
|
||||
|
||||
const noteId = ref<string | null>(props.note?.id ?? null);
|
||||
const title = ref(props.note?.title ?? "");
|
||||
@@ -40,7 +38,6 @@ const root = ref<HTMLElement | null>(null);
|
||||
const bodyInput = ref<HTMLTextAreaElement | null>(null);
|
||||
const fileInput = ref<HTMLInputElement | null>(null);
|
||||
const uploadError = ref("");
|
||||
const backlinks = ref<{ id: string; title: string }[]>([]);
|
||||
|
||||
// Baseline for edit-mode change detection (save only when text actually changed).
|
||||
const baseline = ref<{ title: string | null; body: string; color: NoteColor }>({
|
||||
@@ -163,7 +160,6 @@ function resetCompose(): void {
|
||||
labelList.value = [];
|
||||
createKind.value = "text";
|
||||
baseline.value = { title: null, body: "", color: "default" };
|
||||
linkMenu.value = false;
|
||||
uploadError.value = "";
|
||||
}
|
||||
|
||||
@@ -242,22 +238,7 @@ function onBackdropMousedown(): void {
|
||||
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 () => {
|
||||
void titles.load();
|
||||
void loadBacklinks();
|
||||
await nextTick();
|
||||
const el = bodyInput.value;
|
||||
el?.focus();
|
||||
@@ -265,99 +246,6 @@ onMounted(async () => {
|
||||
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) {
|
||||
// Compose: Shift+Enter saves the note and starts a fresh one (rapid capture).
|
||||
if (isCreate.value && e.key === "Enter" && e.shiftKey) {
|
||||
@@ -365,25 +253,6 @@ function onBodyKeydown(e: KeyboardEvent) {
|
||||
void commitAndContinue();
|
||||
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) {
|
||||
@@ -694,37 +563,15 @@ function revPreview(rev: NoteRevision): string {
|
||||
@keydown.enter="onTitleEnter"
|
||||
/>
|
||||
|
||||
<div v-if="!showChecklist" class="relative">
|
||||
<textarea
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@input="onBodyInput"
|
||||
@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>
|
||||
<textarea
|
||||
v-if="!showChecklist"
|
||||
ref="bodyInput"
|
||||
v-model="body"
|
||||
rows="8"
|
||||
:placeholder="bodyPlaceholder"
|
||||
class="w-full resize-none bg-transparent text-sm leading-relaxed outline-none placeholder:text-neutral-400"
|
||||
@keydown="onBodyKeydown"
|
||||
/>
|
||||
<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">
|
||||
@@ -793,43 +640,6 @@ function revPreview(rev: NoteRevision): string {
|
||||
</button>
|
||||
</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>
|
||||
|
||||
|
||||
@@ -8,8 +8,8 @@ import { captureMorphOrigin } from "./useEditorMorph";
|
||||
//
|
||||
// - `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
|
||||
// [[wiki-link]] target before falling back to the store, then a fetch — so views
|
||||
// that keep their own list (reminders, timeline, search, graph) still resolve
|
||||
// note id before falling back to the store, then a fetch — so views
|
||||
// that keep their own list (reminders, timeline, search) still resolve
|
||||
// locally without duplicating the lookup.
|
||||
export function useNoteEditor(options: { onClose?: () => void; list?: () => Note[] } = {}) {
|
||||
const notes = useNotesStore();
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
// 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
|
||||
// small subset — headings (#..###), unordered/ordered lists, blockquote, fenced
|
||||
// code, and inline **bold** / *italic* / _italic_ / `code` — plus ThoughtSync's own
|
||||
// [[wiki-links]]. Note: headings need a space after `#`, so a #tag (no space) is
|
||||
// left as plain text and never mistaken for a heading.
|
||||
// code, and inline **bold** / *italic* / _italic_ / `code`. Note: headings need a
|
||||
// space after `#`, so a #tag (no space) is left as plain text and never mistaken for
|
||||
// a heading.
|
||||
|
||||
export interface InlineToken {
|
||||
type: "text" | "bold" | "italic" | "code" | "link";
|
||||
type: "text" | "bold" | "italic" | "code";
|
||||
value: string;
|
||||
}
|
||||
|
||||
@@ -19,9 +19,13 @@ export interface Block {
|
||||
value?: string;
|
||||
}
|
||||
|
||||
// Order matters: links + code are matched before emphasis so their contents aren't
|
||||
// re-parsed; bold (**) before italic (*). Emphasis does not nest (v1).
|
||||
const INLINE_RE = /(\[\[[^[\]]+\]\])|(`[^`]+`)|(\*\*[^*]+\*\*)|(\*[^*]+\*)|(_[^_]+_)/g;
|
||||
// Order matters: code is matched before emphasis so its contents aren't re-parsed;
|
||||
// bold (**) before italic (*). Emphasis does not nest (v1).
|
||||
//
|
||||
// `[[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[] {
|
||||
const tokens: InlineToken[] = [];
|
||||
@@ -31,9 +35,8 @@ export function parseInline(text: string): InlineToken[] {
|
||||
while ((m = INLINE_RE.exec(text)) !== null) {
|
||||
if (m.index > last) tokens.push({ type: "text", value: text.slice(last, m.index) });
|
||||
const raw = m[0];
|
||||
if (m[1]) tokens.push({ type: "link", value: raw.slice(2, -2).trim() });
|
||||
else if (m[2]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||
else if (m[3]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||
if (m[1]) tokens.push({ type: "code", value: raw.slice(1, -1) });
|
||||
else if (m[2]) tokens.push({ type: "bold", value: raw.slice(2, -2) });
|
||||
else tokens.push({ type: "italic", value: raw.slice(1, -1) });
|
||||
last = m.index + raw.length;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ const router = createRouter({
|
||||
{ path: "trash", name: "trash", 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: "graph", name: "graph", component: () => import("../views/GraphView.vue") },
|
||||
{ path: "reminders", name: "reminders", component: () => import("../views/RemindersView.vue") },
|
||||
{ path: "timeline", name: "timeline", component: () => import("../views/TimelineView.vue") },
|
||||
],
|
||||
|
||||
@@ -63,27 +63,11 @@ export interface NoteRevision {
|
||||
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 {
|
||||
id: string;
|
||||
title: string | null;
|
||||
// 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;
|
||||
body: string;
|
||||
color: NoteColor;
|
||||
@@ -101,11 +85,6 @@ export interface Note {
|
||||
items: ChecklistItem[];
|
||||
attachments: Attachment[];
|
||||
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;
|
||||
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> {
|
||||
// Optimistically assign positions matching the backend (total - index), sort,
|
||||
// then persist.
|
||||
@@ -327,7 +300,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
deletePreview,
|
||||
importNotes,
|
||||
fetchOne,
|
||||
createTitled,
|
||||
reorder,
|
||||
trash,
|
||||
restore,
|
||||
|
||||
@@ -7,7 +7,12 @@ export interface TitleEntry {
|
||||
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", () => {
|
||||
const items = ref<TitleEntry[]>([]);
|
||||
const loaded = ref(false);
|
||||
@@ -23,10 +28,5 @@ export const useTitlesStore = defineStore("titles", () => {
|
||||
await load();
|
||||
}
|
||||
|
||||
function resolve(title: string): TitleEntry | null {
|
||||
const norm = title.trim().toLowerCase();
|
||||
return items.value.find((t) => t.title.trim().toLowerCase() === norm) ?? null;
|
||||
}
|
||||
|
||||
return { items, loaded, load, reload, resolve };
|
||||
return { items, loaded, load, reload };
|
||||
});
|
||||
|
||||
@@ -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 .config import Config
|
||||
from .db import session_scope
|
||||
from .graph import bp as graph_bp
|
||||
from .labels import bp as labels_bp
|
||||
from .notes import bp as notes_bp
|
||||
from .retention import run_sweeper
|
||||
@@ -78,7 +77,6 @@ def create_app() -> Quart:
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(notes_bp)
|
||||
app.register_blueprint(labels_bp)
|
||||
app.register_blueprint(graph_bp)
|
||||
app.register_blueprint(settings_bp)
|
||||
app.register_blueprint(sync_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_attachment,
|
||||
note_item,
|
||||
note_link,
|
||||
note_link_preview,
|
||||
note_revision,
|
||||
saved_filter,
|
||||
|
||||
@@ -40,9 +40,9 @@ class Note(Base):
|
||||
)
|
||||
title: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
# 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
|
||||
# note — even a body-only one — is nameable, searchable, graphable, and
|
||||
# [[wiki-link]]-able without forcing the user to type a title.
|
||||
# line (see notes.derive_display_title). Persisted so every note — even a body-only
|
||||
# one — has something to be called in search results and in an export filename,
|
||||
# without forcing the user to type a title.
|
||||
display_title: 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")
|
||||
|
||||
@@ -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).
|
||||
|
||||
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
|
||||
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.
|
||||
@@ -19,7 +19,7 @@ import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
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 ..auth import login_required
|
||||
@@ -32,7 +32,6 @@ from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link import NoteLink
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
from ..models.note_revision import NoteRevision
|
||||
from ..responses import json_error, not_found, parse_uuid
|
||||
@@ -44,7 +43,6 @@ from .helpers import (
|
||||
ALLOWED_IMAGE_MIMES,
|
||||
VALID_FILTERS,
|
||||
_attachment_ext,
|
||||
_escape_like,
|
||||
_get_owned,
|
||||
_header_filename,
|
||||
_safe_filename,
|
||||
@@ -65,11 +63,8 @@ from .import_export import (
|
||||
_read_import_specs,
|
||||
_usec_to_dt,
|
||||
)
|
||||
from .links import (
|
||||
from .tags import (
|
||||
_reconcile_tags,
|
||||
_claim_unresolved_links,
|
||||
_rewrite_links,
|
||||
parse_link_titles,
|
||||
parse_tags,
|
||||
)
|
||||
from .recurrence import REMINDER_RECURRENCES, next_occurrence, normalize_recurrence
|
||||
@@ -81,15 +76,11 @@ __all__ = [
|
||||
"is_empty_note",
|
||||
"parse_list_items",
|
||||
"parse_tags",
|
||||
"parse_link_titles",
|
||||
"normalize_color",
|
||||
"normalize_recurrence",
|
||||
"next_occurrence",
|
||||
"_reconcile_tags",
|
||||
"_claim_unresolved_links",
|
||||
"_rewrite_links",
|
||||
"_serialize_notes",
|
||||
"_escape_like",
|
||||
"_safe_filename",
|
||||
"_attachment_ext",
|
||||
"_header_filename",
|
||||
@@ -378,9 +369,13 @@ async def import_notes():
|
||||
@bp.get("/titles")
|
||||
@login_required
|
||||
async def list_titles():
|
||||
# Owner's non-trashed notes, keyed by their display NAME (explicit title or
|
||||
# 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.
|
||||
"""Owner's non-trashed notes, keyed by their display NAME.
|
||||
|
||||
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:
|
||||
rows = (
|
||||
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")
|
||||
@login_required
|
||||
async def reorder_notes():
|
||||
@@ -525,11 +448,7 @@ async def create_note():
|
||||
await db.flush() # assign note.id before writing items/links
|
||||
for pos, text in enumerate(item_texts):
|
||||
db.add(NoteItem(note_id=note.id, text=text, position=pos))
|
||||
await _rewrite_links(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.refresh(note)
|
||||
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)
|
||||
if note is None:
|
||||
return not_found()
|
||||
old_display = note.display_title
|
||||
old_title = note.title
|
||||
old_body = note.body
|
||||
if "title" in data:
|
||||
@@ -591,14 +509,7 @@ async def update_note(note_id: str):
|
||||
if "title" in data or "body" in data:
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
if "body" in data:
|
||||
await _rewrite_links(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.
|
||||
if note.title != old_title or note.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
|
||||
# Snapshot the CURRENT state first, so restoring is itself undoable, then apply
|
||||
# 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))
|
||||
note.title = rev.title
|
||||
note.body = rev.body
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
await _rewrite_links(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.refresh(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:
|
||||
"""A filesystem-safe slug from a note's display name (for the .md filename)."""
|
||||
s = re.sub(r"[^\w\s-]", "", (text or "").strip().lower())
|
||||
|
||||
@@ -28,7 +28,7 @@ from .helpers import (
|
||||
derive_display_title,
|
||||
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
|
||||
|
||||
|
||||
@@ -333,6 +333,5 @@ async def _create_imported_note(
|
||||
if isinstance(att, dict):
|
||||
_import_attachment(db, note, zf, att, budget)
|
||||
|
||||
await _rewrite_links(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
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)
|
||||
into the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
|
||||
"""Note serialization — turn a Note (+ its labels/items/attachments/previews) into
|
||||
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."""
|
||||
from __future__ import annotations
|
||||
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import aliased
|
||||
from sqlalchemy import select
|
||||
|
||||
from ..models.label import Label, NoteLabel
|
||||
from ..models.note import Note
|
||||
from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_item import NoteItem
|
||||
from ..models.note_link import NoteLink
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
|
||||
|
||||
@@ -106,64 +104,6 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
|
||||
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:
|
||||
data = note.serialize()
|
||||
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, [])
|
||||
previews = await _previews_for_notes(db, [note.id])
|
||||
data["previews"] = previews.get(note.id, [])
|
||||
links = await _links_for_notes(db, [note.id])
|
||||
data["links"] = links.get(note.id, [])
|
||||
return data
|
||||
|
||||
|
||||
@@ -185,7 +123,6 @@ async def _serialize_notes(db, notes: list) -> list:
|
||||
items_map = await _items_for_notes(db, ids)
|
||||
attach_map = await _attachments_for_notes(db, ids)
|
||||
preview_map = await _previews_for_notes(db, ids)
|
||||
link_map = await _links_for_notes(db, ids)
|
||||
out = []
|
||||
for n in notes:
|
||||
data = n.serialize()
|
||||
@@ -193,6 +130,5 @@ async def _serialize_notes(db, notes: list) -> list:
|
||||
data["items"] = items_map.get(n.id, [])
|
||||
data["attachments"] = attach_map.get(n.id, [])
|
||||
data["previews"] = preview_map.get(n.id, [])
|
||||
data["links"] = link_map.get(n.id, [])
|
||||
out.append(data)
|
||||
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_attachment import NoteAttachment
|
||||
from .models.note_item import NoteItem
|
||||
from .models.note_link import NoteLink
|
||||
from .models.note_link_preview import NoteLinkPreview
|
||||
from .models.note_revision import NoteRevision
|
||||
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(NoteItem).where(NoteItem.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(NoteRevision).where(NoteRevision.note_id == note.id))
|
||||
note.title = None
|
||||
|
||||
+1
-10
@@ -28,8 +28,6 @@ from .models.note_item import NoteItem
|
||||
from .models.note_revision import NoteRevision
|
||||
from .notes import (
|
||||
_reconcile_tags,
|
||||
_claim_unresolved_links,
|
||||
_rewrite_links,
|
||||
_serialize_notes,
|
||||
derive_display_title,
|
||||
normalize_color,
|
||||
@@ -282,7 +280,7 @@ async def _apply_note(db, ch: dict) -> dict:
|
||||
elif note.purged_at is not None:
|
||||
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)
|
||||
note.display_title = derive_display_title(note.title, note.body)
|
||||
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))
|
||||
await db.flush() # assign note.id before items/labels/links
|
||||
await _apply_note_items(db, note, ch)
|
||||
await _rewrite_links(db, note)
|
||||
await _reconcile_tags(db, note)
|
||||
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.refresh(note, ["sync_revision"])
|
||||
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.notes import (
|
||||
_attachment_ext,
|
||||
_escape_like,
|
||||
_header_filename,
|
||||
_keep_spec,
|
||||
_native_spec,
|
||||
@@ -19,7 +18,6 @@ from thoughtsync.notes import (
|
||||
next_occurrence,
|
||||
normalize_color,
|
||||
normalize_recurrence,
|
||||
parse_link_titles,
|
||||
parse_list_items,
|
||||
parse_tags,
|
||||
)
|
||||
@@ -40,7 +38,7 @@ def test_all_note_routes_registered(app):
|
||||
for name in (
|
||||
"list_notes", "search_notes", "list_reminders", "complete_reminder",
|
||||
"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",
|
||||
"set_note_labels", "add_item", "update_item", "delete_item",
|
||||
"reorder_items", "upload_attachment", "get_attachment",
|
||||
@@ -117,32 +115,11 @@ async def test_reorder_requires_auth(app):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_parse_link_titles():
|
||||
titles = parse_link_titles("see [[Alpha]] and [[ beta ]] and [[Alpha]] again")
|
||||
assert titles == ["alpha", "beta"]
|
||||
|
||||
|
||||
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"]
|
||||
# [[wiki-links]] are gone entirely (note 2897), and with them backlinks, the graph,
|
||||
# the name index and the `[[` autocomplete. So are the two helpers that used to keep
|
||||
# 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_derive_display_title_explicit_wins():
|
||||
@@ -188,14 +165,6 @@ def test_parse_list_items():
|
||||
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():
|
||||
# A full ISO instant round-trips (used to validate the Timeline date range).
|
||||
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
|
||||
|
||||
|
||||
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):
|
||||
client = app.test_client()
|
||||
resp = await client.get("/api/notes/reminders")
|
||||
|
||||
Reference in New Issue
Block a user