0.2.0 — a notebook in your pocket, ready to be hosted #3

Merged
bvandeusen merged 22 commits from dev into main 2026-08-23 16:38:00 -04:00
38 changed files with 216 additions and 1485 deletions
Showing only changes of commit bc22f8e249 - Show all commits
+54
View File
@@ -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"])
+7 -54
View File
@@ -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());
}
}
+1 -7
View File
@@ -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,
+2 -1
View File
@@ -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.
-79
View File
@@ -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()
-18
View File
@@ -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())?;
-3
View File
@@ -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
View File
@@ -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,
+1 -4
View File
@@ -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: {
-7
View File
@@ -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 {
-5
View File
@@ -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: {
+2 -13
View File
@@ -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") },
-1
View File
@@ -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"/>',
+5 -53
View File
@@ -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'"
+8 -11
View File
@@ -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>
+1 -1
View File
@@ -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
+9 -199
View File
@@ -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>
+2 -2
View File
@@ -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();
+13 -10
View File
@@ -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;
}
-1
View File
@@ -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") },
],
+1 -29
View File
@@ -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
View File
@@ -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 };
});
-420
View File
@@ -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>
-2
View File
@@ -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)
-117
View File
@@ -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})
-1
View File
@@ -10,7 +10,6 @@ from . import ( # noqa: F401
note,
note_attachment,
note_item,
note_link,
note_link_preview,
note_revision,
saved_filter,
+3 -3
View File
@@ -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")
-47
View File
@@ -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)
+10 -104
View File
@@ -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))
-5
View File
@@ -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())
+1 -2
View File
@@ -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
-146
View File
@@ -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)
+3 -67
View File
@@ -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
+75
View File
@@ -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)
-2
View File
@@ -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
View File
@@ -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
View File
@@ -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")