all: delete the code nothing calls
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Failing after 1m21s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Successful in 2m47s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m48s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 3m39s
Desktop (Tauri) / Update manifest (push) Successful in 3s
Android / Kotlin + Rust (APK) (push) Successful in 8m37s
From the audit (#5178). Each was unreachable from every client: - Checklist add-item and delete-item: REST POST /items and DELETE /items/<id>, the Tauri commands, the store, rest and local adapters, the core's add_item/delete_item, set_item_text and remove_item, and the FFI exports. Adding, rewording and removing an item are body edits in every editor. The checked toggle stays, and its rewriter is simpler without the drop branch. - Manual unfurl: POST /unfurl and its adapters. Previews arrive in the background after a save (unfurl_queue). - The /api/config `android_client` key, android_release() and the APK_NAME/MANIFEST_NAME aliases. Phones poll /api/client/android. - users.email_verified and users.avatar_path (migration 0037). Nothing set the first or read the second; the SMTP reset never checked verification. - derive::extract_tags (only tests used it; the shared fixture now runs through extract_tag_spans), the unused check and link icons, and the unused editor_add_item string. - The blob scheme is renamed tsblob -> inkblob. URLs are built as notes are read, so nothing stored carries the old one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
"""users: drop email_verified and avatar_path, which nothing used
|
||||
|
||||
Revision ID: 0037
|
||||
Revises: 0036
|
||||
Create Date: 2026-10-07
|
||||
|
||||
Both came with the foundation (0001) for features that were never built (#5178).
|
||||
Nothing ever set `email_verified`, so it was false for every account on the server
|
||||
while the offline desktop reported it as true; nothing read or wrote `avatar_path`.
|
||||
A field whose value means nothing is worse than no field: a later feature would
|
||||
trust it. If address verification is ever built, it adds its own column, with a
|
||||
migration that says what the existing accounts are.
|
||||
|
||||
## Downgrade
|
||||
|
||||
Adds both columns back empty: every account unverified, no avatar. That is what
|
||||
they held before.
|
||||
"""
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0037"
|
||||
down_revision = "0036"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.drop_column("users", "email_verified")
|
||||
op.drop_column("users", "avatar_path")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.add_column("users", sa.Column("avatar_path", sa.Text(), nullable=True))
|
||||
op.add_column(
|
||||
"users",
|
||||
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default=sa.false()),
|
||||
)
|
||||
@@ -40,7 +40,6 @@
|
||||
<string name="editor_back">Back to notes</string>
|
||||
<string name="editor_add_checklist">Add a checklist</string>
|
||||
<string name="editor_body_hint">Take a note…</string>
|
||||
<string name="editor_add_item">Add item</string>
|
||||
<string name="editor_remove_item">Remove item</string>
|
||||
<string name="editor_remove_label">Remove tag</string>
|
||||
<string name="editor_reminder">Set a reminder</string>
|
||||
|
||||
+24
-87
@@ -224,53 +224,29 @@ impl Inkwell {
|
||||
}
|
||||
|
||||
// ──────────────────────────── checklist items ────────────────────────────
|
||||
//
|
||||
// Every one of these returns the whole reloaded note rather than the item it
|
||||
// touched. That is the core's shape, and it is the right one for a UI: ticking
|
||||
// a box changes `updated_at` and can change what the board shows, so handing
|
||||
// back only the item would leave Kotlin to guess at the rest.
|
||||
|
||||
pub fn add_item(&self, note_id: String, text: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::add_item(&conn, ¬e_id, &text)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// Retitle one item.
|
||||
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
||||
/// the body, which the editor makes like any other.
|
||||
///
|
||||
/// Split from `set_item_checked` rather than exposing the core's
|
||||
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
||||
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
||||
/// colliding with "set it to null", and two unambiguous calls beat one
|
||||
/// ambiguous one when each is three lines.
|
||||
pub fn set_item_text(
|
||||
&self,
|
||||
note_id: String,
|
||||
item_id: String,
|
||||
text: String,
|
||||
) -> Result<Note, CoreError> {
|
||||
self.patch_item(¬e_id, &item_id, serde_json::json!({ "text": text }))
|
||||
}
|
||||
|
||||
/// Returns the whole reloaded note rather than the item it touched. That is the
|
||||
/// core's shape, and it is the right one for a UI: ticking a box changes
|
||||
/// `updated_at` and can change what the board shows, so handing back only the
|
||||
/// item would leave Kotlin to guess at the rest.
|
||||
pub fn set_item_checked(
|
||||
&self,
|
||||
note_id: String,
|
||||
item_id: String,
|
||||
checked: bool,
|
||||
) -> Result<Note, CoreError> {
|
||||
self.patch_item(
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::update_item(
|
||||
&conn,
|
||||
¬e_id,
|
||||
&item_id,
|
||||
serde_json::json!({ "checked": checked }),
|
||||
&serde_json::json!({ "checked": checked }),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn delete_item(&self, note_id: String, item_id: String) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::delete_item(&conn, ¬e_id, &item_id)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
// ─────────────────────────────── reminders ───────────────────────────────
|
||||
@@ -721,22 +697,6 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
|
||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||
/// block names, so these stay Rust-side.
|
||||
impl Inkwell {
|
||||
/// Apply a `{text}` or `{checked}` patch to one checklist item.
|
||||
///
|
||||
/// The two public setters differ only in the key they write, and the lock +
|
||||
/// convert + map-error dance around it is identical, so it lives once here.
|
||||
fn patch_item(
|
||||
&self,
|
||||
note_id: &str,
|
||||
item_id: &str,
|
||||
changes: serde_json::Value,
|
||||
) -> Result<Note, CoreError> {
|
||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||
local::store::update_item(&conn, note_id, item_id, &changes)
|
||||
.map(Note::from)
|
||||
.map_err(CoreError::store)
|
||||
}
|
||||
|
||||
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||
/// exactly this, and none of them may hold the lock past it.
|
||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||
@@ -868,55 +828,32 @@ mod tests {
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
||||
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
||||
/// splices back into the board rather than re-querying.
|
||||
/// Ticking a box returns the reloaded note, which is what the UI splices back
|
||||
/// into the board rather than re-querying, and leaves the item's text alone.
|
||||
#[test]
|
||||
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
||||
fn ticking_an_item_rewrites_only_its_box() {
|
||||
let dir = scratch_dir();
|
||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||
let note = app
|
||||
.create_note(NoteDraft {
|
||||
body: "Packing".to_string(),
|
||||
items: Some(vec!["socks".to_string()]),
|
||||
items: Some(vec!["socks".to_string(), "charger".to_string()]),
|
||||
})
|
||||
.expect("create");
|
||||
assert_eq!(note.items.len(), 1);
|
||||
assert_eq!(note.items.len(), 2);
|
||||
|
||||
let with_two = app
|
||||
.add_item(note.id.clone(), "charger".to_string())
|
||||
.expect("add");
|
||||
assert_eq!(with_two.items.len(), 2);
|
||||
// Appended, not prepended — a new row belongs at the bottom of the list the
|
||||
// user is looking at.
|
||||
assert_eq!(with_two.items[1].text, "charger");
|
||||
|
||||
let item_id = with_two.items[1].id.clone();
|
||||
let item_id = note.items[1].id.clone();
|
||||
let ticked = app
|
||||
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
||||
.expect("tick");
|
||||
assert!(ticked.items[1].checked);
|
||||
assert_eq!(
|
||||
ticked.items[1].text, "charger",
|
||||
"ticking a box must not disturb its text — both setters rewrite the \
|
||||
same line of the body now, so one clobbering the other is a live risk \
|
||||
rather than a theoretical one"
|
||||
);
|
||||
assert!(!ticked.items[0].checked);
|
||||
assert_eq!(ticked.items[1].text, "charger");
|
||||
|
||||
let renamed = app
|
||||
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
||||
.expect("rename");
|
||||
assert_eq!(renamed.items[1].text, "usb-c cable");
|
||||
assert!(
|
||||
renamed.items[1].checked,
|
||||
"and the same in the other direction"
|
||||
);
|
||||
|
||||
let trimmed = app
|
||||
.delete_item(note.id.clone(), item_id)
|
||||
.expect("delete item");
|
||||
assert_eq!(trimmed.items.len(), 1);
|
||||
assert_eq!(trimmed.items[0].text, "socks");
|
||||
let unticked = app
|
||||
.set_item_checked(note.id.clone(), item_id, false)
|
||||
.expect("untick");
|
||||
assert_eq!(unticked.body, note.body);
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
+36
-109
@@ -52,22 +52,6 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Extract every `#tag` name (without the leading `#`) from `body`.
|
||||
///
|
||||
/// Line by line, which changes nothing: a line start and a `\n` are both boundaries,
|
||||
/// so the same tags come out. It means there is ONE scanner rather than two — this and
|
||||
/// [`lift_standalone_tags`] cannot disagree about what a tag is.
|
||||
pub fn extract_tags(body: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in body.split('\n') {
|
||||
let chars: Vec<char> = line.chars().collect();
|
||||
for (_, _, name) in line_tags(&chars) {
|
||||
push_unique(&mut out, &name);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// One `#tag` and exactly where it sits, for a renderer drawing the body itself.
|
||||
///
|
||||
/// The card no longer prints a chip for a tag whose text is still in the note — it
|
||||
@@ -89,13 +73,13 @@ pub struct DerivedTag {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// Every `#tag` in `body` with its position — the same scan [`extract_tags`] does,
|
||||
/// Every `#tag` in `body` with its position — the scan [`lift_standalone_tags`] does,
|
||||
/// keeping the spans instead of throwing them away.
|
||||
///
|
||||
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
|
||||
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
|
||||
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
|
||||
/// renderer that left it plain would be the only surface disagreeing.
|
||||
/// Not deduped: two mentions of `#todo` are two pieces of text to colour. Fences are
|
||||
/// not skipped either, and that is deliberate — a `#tag` inside a code block stays an
|
||||
/// inline label on the note, and a renderer that left it plain would be the only
|
||||
/// surface disagreeing.
|
||||
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
||||
let mut out = Vec::new();
|
||||
for (n, line) in body.split('\n').enumerate() {
|
||||
@@ -383,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
||||
out
|
||||
}
|
||||
|
||||
/// Rewrite the `index`-th task line, or drop it when `f` returns None.
|
||||
/// Tick or untick the `index`-th item, keeping its indent, bullet and text.
|
||||
///
|
||||
/// The only edit to an item that isn't typing in the body: adding, rewording and
|
||||
/// deleting one are all text edits, which every client makes in its editor.
|
||||
///
|
||||
/// A body with fewer task lines than that is returned UNCHANGED rather than
|
||||
/// panicking: the index comes from a UI that may be a moment behind the store, and
|
||||
/// a stale tap should do nothing rather than take the app down.
|
||||
fn map_task_line<F>(body: &str, index: usize, f: F) -> String
|
||||
where
|
||||
F: FnOnce(&TaskLine<'_>) -> Option<String>,
|
||||
{
|
||||
let lines: Vec<&str> = body.split('\n').collect();
|
||||
let mut target: Option<usize> = None;
|
||||
let mut seen = 0usize;
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
if parse_task_line(line).is_some() {
|
||||
if seen == index {
|
||||
target = Some(n);
|
||||
break;
|
||||
}
|
||||
seen += 1;
|
||||
}
|
||||
}
|
||||
let target = match target {
|
||||
Some(n) => n,
|
||||
None => return body.to_string(),
|
||||
};
|
||||
let replacement = match parse_task_line(lines[target]) {
|
||||
Some(parsed) => f(&parsed),
|
||||
None => return body.to_string(),
|
||||
};
|
||||
|
||||
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
||||
for (n, line) in lines.iter().enumerate() {
|
||||
if n != target {
|
||||
out.push((*line).to_string());
|
||||
} else if let Some(new_line) = &replacement {
|
||||
out.push(new_line.clone());
|
||||
}
|
||||
// None at the target line drops it, which is `remove_item`.
|
||||
}
|
||||
out.join("\n")
|
||||
}
|
||||
|
||||
/// Tick or untick the `index`-th item.
|
||||
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
||||
map_task_line(body, index, |t| {
|
||||
Some(render_task_line(t.indent, t.bullet, checked, t.text))
|
||||
})
|
||||
}
|
||||
|
||||
/// Replace the text of the `index`-th item, keeping its state and its bullet.
|
||||
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
|
||||
map_task_line(body, index, |t| {
|
||||
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the `index`-th item, line and all.
|
||||
pub fn remove_item(body: &str, index: usize) -> String {
|
||||
map_task_line(body, index, |_| None)
|
||||
let mut lines: Vec<String> = body.split('\n').map(str::to_string).collect();
|
||||
let Some(line) = lines
|
||||
.iter_mut()
|
||||
.filter(|l| parse_task_line(l.as_str()).is_some())
|
||||
.nth(index)
|
||||
else {
|
||||
return body.to_string();
|
||||
};
|
||||
let Some(t) = parse_task_line(line.as_str()) else {
|
||||
return body.to_string();
|
||||
};
|
||||
let ticked = render_task_line(t.indent, t.bullet, checked, t.text);
|
||||
*line = ticked;
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Add an item at the end of the body.
|
||||
@@ -476,10 +424,19 @@ pub fn append_item(body: &str, text: &str, checked: bool) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The tag names in `body`, once each — what the shared fixture lists.
|
||||
fn tag_names(body: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for t in extract_tag_spans(body) {
|
||||
push_unique(&mut out, &t.name);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_basic() {
|
||||
assert_eq!(
|
||||
extract_tags("a #todo and #Work-item_2 here"),
|
||||
tag_names("a #todo and #Work-item_2 here"),
|
||||
vec!["todo", "Work-item_2"]
|
||||
);
|
||||
}
|
||||
@@ -487,17 +444,12 @@ mod tests {
|
||||
#[test]
|
||||
fn tags_require_letter_start_and_boundary() {
|
||||
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tags_dedupe_case_insensitive() {
|
||||
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
||||
assert_eq!(tag_names("#1 nope a#b no but #Yes ##no"), vec!["Yes"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_body() {
|
||||
assert!(extract_tags("").is_empty());
|
||||
assert!(extract_tag_spans("").is_empty());
|
||||
}
|
||||
|
||||
// ── tag spans, for the renderer that draws them in place ─────────────────
|
||||
@@ -523,16 +475,6 @@ mod tests {
|
||||
assert_eq!((spans[0].start, spans[0].end), (3, 8));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_spans_agree_with_extract_tags_about_what_a_tag_is() {
|
||||
let body = "#1 nope a#b no but #Yes ##no";
|
||||
let names: Vec<String> = extract_tag_spans(body)
|
||||
.into_iter()
|
||||
.map(|t| t.name)
|
||||
.collect();
|
||||
assert_eq!(names, extract_tags(body));
|
||||
}
|
||||
|
||||
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||
//
|
||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
||||
@@ -697,17 +639,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_text_keeps_state() {
|
||||
assert_eq!(set_item_text("- [x] old", 0, "new"), "- [x] new");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_takes_the_whole_line() {
|
||||
let body = "keep\n- [ ] drop\n- [ ] stay";
|
||||
assert_eq!(remove_item(body, 0), "keep\n- [ ] stay");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_spaces_like_the_exporter() {
|
||||
// Prose then a blank line then the list — byte-for-byte what
|
||||
@@ -755,16 +686,12 @@ mod tests {
|
||||
// that arrives late should be inert, not fatal.
|
||||
let body = "- [ ] only";
|
||||
assert_eq!(set_item_checked(body, 7, true), body);
|
||||
assert_eq!(remove_item(body, 7), body);
|
||||
assert_eq!(set_item_text(body, 7, "x"), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_body_is_returned_byte_identical() {
|
||||
let body = "just prose\nwith two lines";
|
||||
assert_eq!(set_item_checked(body, 0, true), body);
|
||||
assert_eq!(set_item_text(body, 0, "x"), body);
|
||||
assert_eq!(remove_item(body, 0), body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -828,7 +755,7 @@ mod tests {
|
||||
fn fixture_tags() {
|
||||
for case in fixture()["tags"].as_array().unwrap() {
|
||||
let body = case["body"].as_str().unwrap();
|
||||
assert_eq!(extract_tags(body), strings(&case["tags"]), "body {body:?}");
|
||||
assert_eq!(tag_names(body), strings(&case["tags"]), "body {body:?}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -147,7 +147,6 @@ pub struct User {
|
||||
pub id: String,
|
||||
pub email: String,
|
||||
pub display_name: String,
|
||||
pub email_verified: bool,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
|
||||
+10
-26
@@ -756,14 +756,13 @@ pub fn set_labels(conn: &Connection, id: &str, label_ids: &[String]) -> rusqlite
|
||||
load_note(conn, id)
|
||||
}
|
||||
|
||||
// ---- checklist items: every one of these is a body edit ---------------------
|
||||
// ---- checklist items: a tick is a body edit ----------------------------------
|
||||
//
|
||||
// They keep their own names and signatures because the FFI, the Tauri commands and
|
||||
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
|
||||
// What changed is where it is kept. Routing all three through `update_note` rather
|
||||
// than writing the body directly is what gives them revision snapshotting, `#tag`
|
||||
// re-derivation and the dirty/updated_at bookkeeping without any of it being
|
||||
// written a second time here.
|
||||
// It keeps the item's name and signature because the FFI, the Tauri commands and the
|
||||
// REST shape all speak in items, and a checklist is still a thing a note HAS. Routing
|
||||
// it through `update_note` rather than writing the body directly is what gives it
|
||||
// revision snapshotting, `#tag` re-derivation and the dirty/updated_at bookkeeping
|
||||
// without any of it being written a second time here.
|
||||
|
||||
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
|
||||
conn.query_row("SELECT body FROM notes WHERE id = ?1", [id], |r| r.get(0))
|
||||
@@ -779,11 +778,8 @@ fn set_body(conn: &Connection, id: &str, body: String) -> rusqlite::Result<Note>
|
||||
update_note(conn, id, &json!({ "body": body }))
|
||||
}
|
||||
|
||||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::append_item(&body, text, false))
|
||||
}
|
||||
|
||||
/// Tick or untick one item. The only item change that isn't an edit to the body's
|
||||
/// text: adding, rewording and removing an item happen in the editor.
|
||||
pub fn update_item(
|
||||
conn: &Connection,
|
||||
id: &str,
|
||||
@@ -795,24 +791,12 @@ pub fn update_item(
|
||||
None => return load_note(conn, id),
|
||||
};
|
||||
let mut body = note_body(conn, id)?;
|
||||
if let Some(text) = changes.get("text").and_then(Value::as_str) {
|
||||
body = derive::set_item_text(&body, index, text);
|
||||
}
|
||||
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||
body = derive::set_item_checked(&body, index, checked);
|
||||
}
|
||||
set_body(conn, id, body)
|
||||
}
|
||||
|
||||
pub fn delete_item(conn: &Connection, id: &str, item_id: &str) -> rusqlite::Result<Note> {
|
||||
let index = match item_index(item_id) {
|
||||
Some(i) => i,
|
||||
None => return load_note(conn, id),
|
||||
};
|
||||
let body = note_body(conn, id)?;
|
||||
set_body(conn, id, derive::remove_item(&body, index))
|
||||
}
|
||||
|
||||
/// The stored form of a declared content type: the bare media type, lowercase.
|
||||
/// Matches the server's `normalize_mime`, so both sides file a type the same way.
|
||||
fn normalize_mime(raw: &str) -> String {
|
||||
@@ -1355,7 +1339,7 @@ mod tests {
|
||||
.expect("refused");
|
||||
// The words the person sees, exactly: the refusal prints as its message.
|
||||
assert_eq!(refused.to_string(), VIEW_ONLY);
|
||||
assert!(add_item(&conn, "n", "eggs").is_err());
|
||||
assert!(update_item(&conn, "n", "0", &json!({ "checked": true })).is_err());
|
||||
assert!(trash(&conn, "n").is_err());
|
||||
assert!(snooze_reminder(&conn, "n", 10).is_err());
|
||||
assert!(set_labels(&conn, "n", &[]).is_err());
|
||||
@@ -1374,7 +1358,7 @@ mod tests {
|
||||
assert!(edited.labels.is_empty());
|
||||
assert_eq!(edited.body, "their words, edited\n#mine");
|
||||
assert!(dirty(&conn, "n"));
|
||||
add_item(&conn, "n", "eggs").expect("an item is text");
|
||||
update_item(&conn, "n", "0", &json!({ "checked": true })).expect("a tick is text");
|
||||
|
||||
let reminded = update_note(
|
||||
&conn,
|
||||
|
||||
@@ -104,8 +104,9 @@ impl BlobStore {
|
||||
// store. The webview then caches and range-requests them like any other resource,
|
||||
// which a `data:` URI would have thrown away.
|
||||
|
||||
/// The scheme the webview fetches attachment bytes over.
|
||||
pub const BLOB_SCHEME: &str = "tsblob";
|
||||
/// The scheme the webview fetches attachment bytes over. Nothing stores a URL built
|
||||
/// on it — `url_for` runs as each note is read — so renaming it costs nothing.
|
||||
pub const BLOB_SCHEME: &str = "inkblob";
|
||||
|
||||
/// The blob directory, published once the app has resolved its data dir.
|
||||
///
|
||||
@@ -279,9 +280,9 @@ mod tests {
|
||||
// The platform split is the whole risk of this feature, and CI is headless,
|
||||
// so at least pin that the right branch was taken for THIS build.
|
||||
if cfg!(any(windows, target_os = "android")) {
|
||||
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
|
||||
assert!(url.starts_with("http://inkblob.localhost/"), "{url}");
|
||||
} else {
|
||||
assert!(url.starts_with("tsblob://localhost/"), "{url}");
|
||||
assert!(url.starts_with("inkblob://localhost/"), "{url}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ pub fn auth_me() -> User {
|
||||
id: "local".to_string(),
|
||||
email: "local@inkwell.app".to_string(),
|
||||
display_name: "You".to_string(),
|
||||
email_verified: true,
|
||||
is_admin: false,
|
||||
}
|
||||
}
|
||||
@@ -99,12 +98,6 @@ pub fn notes_set_labels(
|
||||
store::set_labels(&conn, &id, &label_ids).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn notes_add_item(id: String, text: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::add_item(&conn, &id, &text).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn notes_update_item(
|
||||
id: String,
|
||||
@@ -116,12 +109,6 @@ pub fn notes_update_item(
|
||||
store::update_item(&conn, &id, &item_id, &changes).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn notes_delete_item(id: String, item_id: String, db: State<'_, Db>) -> Result<Note, String> {
|
||||
let conn = db.0.lock().map_err(|e| e.to_string())?;
|
||||
store::delete_item(&conn, &id, &item_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Attach a file to a note, online or not (#5168).
|
||||
///
|
||||
/// The file's bytes are the raw IPC body — a JSON number array would be several
|
||||
|
||||
@@ -180,9 +180,7 @@ pub fn run() {
|
||||
commands::local::notes_complete_reminder,
|
||||
commands::local::notes_snooze_reminder,
|
||||
commands::local::notes_set_labels,
|
||||
commands::local::notes_add_item,
|
||||
commands::local::notes_update_item,
|
||||
commands::local::notes_delete_item,
|
||||
commands::local::notes_add_attachment,
|
||||
commands::local::notes_export,
|
||||
commands::local::notes_import,
|
||||
|
||||
@@ -135,8 +135,10 @@ address can be sent.
|
||||
|
||||
Know these before you decide who gets an account.
|
||||
|
||||
- **No email verification.** `email_verified` exists on the user row and nothing sets
|
||||
it. Email is used only for password resets (above).
|
||||
- **No email verification.** An account's address is whatever was typed when it
|
||||
registered, and email is used only for password resets (above), which go to that
|
||||
address. Nothing changes an address afterwards, so someone who registered with a mistyped
|
||||
one can't reset by email; an admin can issue them a reset link instead.
|
||||
- **An admin who forgets their own password**, with email off and no other admin,
|
||||
still needs a hand on the database.
|
||||
- **No second factor.** A password is the whole of it.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//
|
||||
// Argument keys are camelCase; Tauri converts them to the Rust commands' snake_case
|
||||
// parameters (e.g. labelIds -> label_ids). A few operations have no offline meaning
|
||||
// yet (account auth, device linking, URL unfurl) — those reject with a clear message
|
||||
// yet (account auth, device linking) — those reject with a clear message
|
||||
// rather than silently failing; the board, editor, attachments, capture, filters,
|
||||
// labels, checklists, reminders, import and export all work fully offline.
|
||||
|
||||
@@ -55,9 +55,7 @@ export const local: Repo = {
|
||||
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
|
||||
addItem: (id, text) => invoke<Note>("notes_add_item", { id, text }),
|
||||
updateItem: (id, itemId, changes) => invoke<Note>("notes_update_item", { id, itemId, changes }),
|
||||
deleteItem: (id, itemId) => invoke<Note>("notes_delete_item", { id, itemId }),
|
||||
// Kept on this device and uploaded by the next sync once linked (#5168). The bytes
|
||||
// go as the raw IPC body; the name is percent-encoded because a header carries
|
||||
// only ASCII.
|
||||
@@ -70,7 +68,6 @@ export const local: Repo = {
|
||||
},
|
||||
}),
|
||||
deleteAttachment: (id, attId) => invoke<Note>("notes_delete_attachment", { id, attId }),
|
||||
unfurl: () => Promise.reject<Note>(new Error(NEEDS_SERVER)),
|
||||
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
|
||||
// The archive crosses as raw bytes, as an attachment does.
|
||||
import: async (file) => invoke<ImportResult>("notes_import", new Uint8Array(await file.arrayBuffer())),
|
||||
|
||||
@@ -40,9 +40,9 @@ export type NoteChanges = Partial<
|
||||
Pick<Note, "body" | "pinned" | "archived" | "remind_at" | "recurrence">
|
||||
>;
|
||||
|
||||
/** The one item change that isn't typing in the body: ticking its box. */
|
||||
export interface ChecklistItemChanges {
|
||||
text?: string;
|
||||
checked?: boolean;
|
||||
checked: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,12 +131,9 @@ export interface NotesRepo {
|
||||
completeReminder(id: string): Promise<Note>;
|
||||
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
||||
setLabels(id: string, labelIds: string[]): Promise<Note>;
|
||||
addItem(id: string, text: string): Promise<Note>;
|
||||
updateItem(id: string, itemId: string, changes: ChecklistItemChanges): Promise<Note>;
|
||||
deleteItem(id: string, itemId: string): Promise<Note>;
|
||||
uploadAttachment(id: string, file: File): Promise<Note>;
|
||||
deleteAttachment(id: string, attId: string): Promise<Note>;
|
||||
unfurl(id: string, url: string): Promise<Note>;
|
||||
deletePreview(id: string, previewId: string): Promise<Note>;
|
||||
import(file: File): Promise<ImportResult>;
|
||||
/** Save every note as a zip. Resolves to the path it was saved at, or null when
|
||||
|
||||
@@ -84,13 +84,10 @@ export const rest: Repo = {
|
||||
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
||||
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
||||
setLabels: (id, labelIds) => api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }),
|
||||
addItem: (id, text) => api.post<Note>(`/api/notes/${id}/items`, { text }),
|
||||
updateItem: (id, itemId, changes: ChecklistItemChanges) =>
|
||||
api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes),
|
||||
deleteItem: (id, itemId) => api.del<Note>(`/api/notes/${id}/items/${itemId}`),
|
||||
uploadAttachment: (id, file) => api.postForm<Note>(`/api/notes/${id}/attachments`, fileForm(file)),
|
||||
deleteAttachment: (id, attId) => api.del<Note>(`/api/notes/${id}/attachments/${attId}`),
|
||||
unfurl: (id, url) => api.post<Note>(`/api/notes/${id}/unfurl`, { url }),
|
||||
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
|
||||
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
|
||||
// A same-origin GET with the session cookie; the browser saves the attachment
|
||||
|
||||
@@ -13,7 +13,6 @@ const paths: Record<string, string> = {
|
||||
tag: '<path d="M12.586 2.586A2 2 0 0 0 11.172 2H4a2 2 0 0 0-2 2v7.172a2 2 0 0 0 .586 1.414l8.704 8.704a2.426 2.426 0 0 0 3.42 0l6.58-6.58a2.426 2.426 0 0 0 0-3.42z"/><circle cx="7.5" cy="7.5" r=".5" fill="currentColor"/>',
|
||||
pencil: '<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z"/><path d="m15 5 4 4"/>',
|
||||
plus: '<path d="M5 12h14"/><path d="M12 5v14"/>',
|
||||
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"/>',
|
||||
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"/>',
|
||||
@@ -27,7 +26,6 @@ const paths: Record<string, string> = {
|
||||
device: '<rect width="14" height="20" x="5" y="2" rx="2" ry="2"/><path d="M12 18h.01"/>',
|
||||
sync: '<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/><path d="M3 21v-5h5"/>',
|
||||
paperclip: '<path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57A4 4 0 1 1 18 8.84l-8.59 8.57a2 2 0 0 1-2.83-2.83l8.49-8.48"/>',
|
||||
link: '<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/>',
|
||||
filter: '<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>',
|
||||
users: '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
|
||||
copy: '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
|
||||
|
||||
@@ -49,10 +49,6 @@ export interface PublicConfig {
|
||||
// Every client this server holds, keyed by platform id. Absent on a server that
|
||||
// holds none, and absent on the desktop's own offline config — the Tauri build
|
||||
// answers `config_get` locally and has no clients to hand out.
|
||||
//
|
||||
// `/api/config` also carries `android_client`, which is NOT declared here: it
|
||||
// exists for phones in the field polling for their own update, not for this app,
|
||||
// and reading it here would be a second path to the same fact.
|
||||
clients?: Record<string, ClientRelease>;
|
||||
}
|
||||
|
||||
|
||||
@@ -184,18 +184,10 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
reconcile(await repo.notes.setLabels(id, labelIds));
|
||||
}
|
||||
|
||||
async function addItem(id: string, text: string): Promise<void> {
|
||||
reconcile(await repo.notes.addItem(id, text));
|
||||
}
|
||||
|
||||
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
|
||||
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
|
||||
reconcile(await repo.notes.updateItem(id, itemId, changes));
|
||||
}
|
||||
|
||||
async function deleteItem(id: string, itemId: string): Promise<void> {
|
||||
reconcile(await repo.notes.deleteItem(id, itemId));
|
||||
}
|
||||
|
||||
async function uploadAttachment(id: string, file: File): Promise<void> {
|
||||
reconcile(await repo.notes.uploadAttachment(id, file));
|
||||
}
|
||||
@@ -204,10 +196,6 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
reconcile(await repo.notes.deleteAttachment(id, attId));
|
||||
}
|
||||
|
||||
async function unfurl(id: string, url: string): Promise<void> {
|
||||
reconcile(await repo.notes.unfurl(id, url));
|
||||
}
|
||||
|
||||
async function deletePreview(id: string, previewId: string): Promise<void> {
|
||||
reconcile(await repo.notes.deletePreview(id, previewId));
|
||||
}
|
||||
@@ -299,12 +287,9 @@ export const useNotesStore = defineStore("notes", () => {
|
||||
snoozeReminder,
|
||||
saveEdit,
|
||||
setLabels,
|
||||
addItem,
|
||||
updateItem,
|
||||
deleteItem,
|
||||
uploadAttachment,
|
||||
deleteAttachment,
|
||||
unfurl,
|
||||
deletePreview,
|
||||
importNotes,
|
||||
fetchOne,
|
||||
|
||||
@@ -6,7 +6,6 @@ export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
email_verified: boolean;
|
||||
is_admin: boolean;
|
||||
}
|
||||
|
||||
|
||||
+3
-4
@@ -212,10 +212,9 @@ def create_app() -> Quart:
|
||||
# linking — while it still has no token and possibly no account — to decide
|
||||
# whether it can talk to this server, and which optional features to offer.
|
||||
data.update(protocol_advertisement())
|
||||
# Which CLIENTS this server can hand out, if any — the whole set under
|
||||
# `clients`, plus the older `android_client` key that phones in the field
|
||||
# still read. Absent rather than null when it has none, so the web UI hides
|
||||
# a download instead of offering a button that 404s.
|
||||
# Which CLIENTS this server can hand out, if any, under `clients`. Absent
|
||||
# rather than null when it has none, so the web UI hides a download instead
|
||||
# of offering a button that 404s.
|
||||
data.update(client_advertisement())
|
||||
return jsonify(data)
|
||||
|
||||
|
||||
@@ -52,7 +52,6 @@ def _serialize_user(user: User) -> dict:
|
||||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"display_name": user.display_name,
|
||||
"email_verified": user.email_verified,
|
||||
"is_admin": user.is_admin,
|
||||
}
|
||||
|
||||
|
||||
@@ -176,12 +176,6 @@ PLATFORMS: tuple[Platform, ...] = (
|
||||
|
||||
BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS}
|
||||
|
||||
# The Android names, still importable under their old spellings because docs and
|
||||
# the CI lane refer to them. Derived from the table rather than restated, so the
|
||||
# two cannot drift.
|
||||
APK_NAME = BY_ID["android"].artifact
|
||||
MANIFEST_NAME = BY_ID["android"].sidecar
|
||||
|
||||
# The copy CI bakes into the image. Inside the package, NOT under DATA_DIR: that
|
||||
# is a volume mount, and a file the image wrote there would vanish behind it.
|
||||
BAKED_ROOT = Path(__file__).resolve().parent / "client"
|
||||
@@ -293,40 +287,17 @@ def releases() -> dict[str, dict]:
|
||||
return {pid: r[1] for pid, r in found.items() if r is not None}
|
||||
|
||||
|
||||
def android_release() -> dict | None:
|
||||
"""What Android build this server holds, or None.
|
||||
|
||||
Kept as its own name because the back-compatible `/api/config` key below is
|
||||
about Android specifically, and because saying so reads better than
|
||||
`release("android")` at the two call sites that mean the phone.
|
||||
"""
|
||||
return release("android")
|
||||
|
||||
|
||||
def advertisement() -> dict:
|
||||
"""The `/api/config` fragment describing this server's clients.
|
||||
"""The `/api/config` fragment describing this server's clients: `clients`, the
|
||||
whole table, which the web UI renders the downloads section from. Phones ask
|
||||
`/api/client/android` for their own update instead.
|
||||
|
||||
Two keys, deliberately, and the older one is not deprecated here:
|
||||
|
||||
`clients` is the whole table, which is what the web UI renders the downloads
|
||||
section from.
|
||||
|
||||
`android_client` is what phones in the field already read. It costs one
|
||||
duplicated dict to not strand every installed Android client, and retiring it
|
||||
is a later decision made when nothing polls it — not a tidy-up done in the
|
||||
change that introduces its replacement.
|
||||
|
||||
Both are ABSENT rather than null when empty, so a client testing for a key gets
|
||||
one unambiguous answer instead of having to distinguish "no client" from "an
|
||||
old server that never had this field".
|
||||
ABSENT rather than empty when the server holds none, so a client testing for the
|
||||
key gets one unambiguous answer instead of having to distinguish "no client" from
|
||||
"an old server that never had this field".
|
||||
"""
|
||||
data: dict = {}
|
||||
found = releases()
|
||||
if found:
|
||||
data["clients"] = found
|
||||
if "android" in found:
|
||||
data["android_client"] = found["android"]
|
||||
return data
|
||||
return {"clients": found} if found else {}
|
||||
|
||||
|
||||
@bp.get("/api/client")
|
||||
|
||||
@@ -16,7 +16,6 @@ class User(Base):
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
|
||||
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
|
||||
email_verified: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
# First registered user becomes admin (see auth.register); admin gates Settings.
|
||||
is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||
# Nullable: leaves room for external-identity-only accounts later (rule 26).
|
||||
@@ -25,6 +24,5 @@ class User(Base):
|
||||
# Moved on by a password reset to sign the account out everywhere: every session
|
||||
# carries the epoch it signed in under, and one from an older epoch is refused.
|
||||
session_epoch: Mapped[int] = mapped_column(Integer(), nullable=False, server_default="0")
|
||||
avatar_path: Mapped[str | None] = mapped_column(Text(), nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||
|
||||
@@ -32,18 +32,11 @@ from ..models.note_attachment import NoteAttachment
|
||||
from ..models.note_link_preview import NoteLinkPreview
|
||||
from ..models.note_revision import NoteRevision
|
||||
from ..note_state import OWN_FIELDS, archived_for, join_state, pinned_for, position_for, set_own_state
|
||||
from .checklist import (
|
||||
append_item,
|
||||
parse_items,
|
||||
remove_item,
|
||||
set_item_checked,
|
||||
set_item_text,
|
||||
)
|
||||
from .checklist import append_item, parse_items, set_item_checked
|
||||
from ..responses import json_error, not_found, parse_uuid
|
||||
from ..retention import purge_note
|
||||
from ..settings import get_setting
|
||||
from ..unfurl_queue import schedule as schedule_unfurls
|
||||
from ..unfurl import UnfurlError, unfurl
|
||||
from ._bp import bp
|
||||
from .body import write_body
|
||||
from .helpers import (
|
||||
@@ -604,22 +597,11 @@ async def _rewrite_body(db, note: Note, body: str):
|
||||
return await _commit_note(db, note, await write_body(db, note, body))
|
||||
|
||||
|
||||
@bp.post("/<note_id>/items")
|
||||
@login_required
|
||||
async def add_item(note_id: str):
|
||||
data = await request.get_json(silent=True) or {}
|
||||
text = data["text"].strip() if isinstance(data.get("text"), str) else ""
|
||||
async with session_scope() as db:
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
response = await _rewrite_body(db, note, append_item(note.body, text))
|
||||
return response, 201
|
||||
|
||||
|
||||
@bp.patch("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
async def update_item(note_id: str, item_id: str):
|
||||
"""Tick or untick one item: the only item change that isn't an edit to the body's
|
||||
text. Adding, rewording and removing items happen in the editor, as body edits."""
|
||||
data = await request.get_json(silent=True) or {}
|
||||
index = _item_index(item_id)
|
||||
if index is None:
|
||||
@@ -631,28 +613,11 @@ async def update_item(note_id: str, item_id: str):
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
body = note.body
|
||||
if "text" in data and isinstance(data["text"], str):
|
||||
body = set_item_text(body, index, data["text"])
|
||||
if "checked" in data:
|
||||
body = set_item_checked(body, index, bool(data["checked"]))
|
||||
return await _rewrite_body(db, note, body)
|
||||
|
||||
|
||||
@bp.delete("/<note_id>/items/<item_id>")
|
||||
@login_required
|
||||
async def delete_item(note_id: str, item_id: str):
|
||||
index = _item_index(item_id)
|
||||
if index is None:
|
||||
return not_found()
|
||||
async with session_scope() as db:
|
||||
note = await _get_editable(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
if index >= len(parse_items(note.body)):
|
||||
return not_found()
|
||||
return await _rewrite_body(db, note, remove_item(note.body, index))
|
||||
|
||||
|
||||
# The reorder route is gone with M304. Reordering a checklist is moving a line, which
|
||||
# is something a text editor already does and no client ever called this for — the
|
||||
# only reference to it in the tree was a test asserting the route existed.
|
||||
@@ -746,46 +711,6 @@ async def delete_attachment(note_id: str, att_id: str):
|
||||
return jsonify(result)
|
||||
|
||||
|
||||
@bp.post("/<note_id>/unfurl")
|
||||
@login_required
|
||||
async def unfurl_link(note_id: str):
|
||||
"""Fetch a link preview for a URL in this note and store it. Opt-in via the
|
||||
enable_url_unfurl setting; SSRF-guarded server-side fetch (see unfurl.py)."""
|
||||
data = await request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
if not url:
|
||||
return json_error("url is required", 400)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
if not await get_setting(db, "enable_url_unfurl"):
|
||||
return json_error("link previews are disabled", 403)
|
||||
# Fetch OUTSIDE the DB session — network IO shouldn't hold a connection.
|
||||
try:
|
||||
preview = await unfurl(url)
|
||||
except UnfurlError as e:
|
||||
return json_error(str(e), 502)
|
||||
async with session_scope() as db:
|
||||
note = await _get_owned(db, note_id)
|
||||
if note is None:
|
||||
return not_found()
|
||||
# Keyed by the ORIGINAL pasted url (what the note body contains, so the client
|
||||
# matches it) — re-unfurling the same link updates the cached preview in place.
|
||||
row = await db.scalar(
|
||||
select(NoteLinkPreview).where(NoteLinkPreview.note_id == note.id, NoteLinkPreview.url == url)
|
||||
)
|
||||
if row is None:
|
||||
row = NoteLinkPreview(note_id=note.id, url=url)
|
||||
db.add(row)
|
||||
row.title = preview["title"]
|
||||
row.description = preview["description"]
|
||||
row.image_url = preview["image_url"]
|
||||
row.site_name = preview["site_name"]
|
||||
await db.commit()
|
||||
return jsonify(await _serialize_note(db, note, g.user_id)), 201
|
||||
|
||||
|
||||
@bp.delete("/<note_id>/previews/<preview_id>")
|
||||
@login_required
|
||||
async def delete_preview(note_id: str, preview_id: str):
|
||||
|
||||
@@ -80,58 +80,24 @@ def strip_marker(line: str) -> str:
|
||||
return (match.group("text") or "") if match else line
|
||||
|
||||
|
||||
def _rewrite(body: str, index: int, replace) -> str:
|
||||
"""Rewrite the `index`-th task line with `replace`, or drop it when `replace`
|
||||
returns None.
|
||||
def set_item_checked(body: str, index: int, checked: bool) -> str:
|
||||
"""Tick or untick the `index`-th item, keeping its text, indent and bullet. The
|
||||
only edit to an item that isn't typing in the body.
|
||||
|
||||
A body with fewer task lines than that is returned UNCHANGED rather than raising:
|
||||
the index comes from a client that may be a moment behind the server, and a stale
|
||||
request should do nothing rather than 500.
|
||||
"""
|
||||
lines = body.split("\n")
|
||||
target = None
|
||||
seen = 0
|
||||
for n, line in enumerate(lines):
|
||||
if _TASK_RE.match(line):
|
||||
if seen == index:
|
||||
target = n
|
||||
break
|
||||
seen += 1
|
||||
if target is None:
|
||||
tasks = [n for n, line in enumerate(lines) if _TASK_RE.match(line)]
|
||||
if index >= len(tasks):
|
||||
return body
|
||||
|
||||
match = _TASK_RE.match(lines[target])
|
||||
replacement = replace(match)
|
||||
if replacement is None:
|
||||
del lines[target]
|
||||
else:
|
||||
lines[target] = replacement
|
||||
target = tasks[index]
|
||||
m = _TASK_RE.match(lines[target])
|
||||
lines[target] = render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet"))
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def set_item_checked(body: str, index: int, checked: bool) -> str:
|
||||
"""Tick or untick the `index`-th item, keeping its text, indent and bullet."""
|
||||
return _rewrite(
|
||||
body,
|
||||
index,
|
||||
lambda m: render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet")),
|
||||
)
|
||||
|
||||
|
||||
def set_item_text(body: str, index: int, text: str) -> str:
|
||||
"""Replace the text of the `index`-th item, keeping its state and its bullet."""
|
||||
return _rewrite(
|
||||
body,
|
||||
index,
|
||||
lambda m: render_item(text.strip(), m.group("mark") in "xX", m.group("indent"), m.group("bullet")),
|
||||
)
|
||||
|
||||
|
||||
def remove_item(body: str, index: int) -> str:
|
||||
"""Delete the `index`-th item, line and all."""
|
||||
return _rewrite(body, index, lambda _m: None)
|
||||
|
||||
|
||||
def append_item(body: str, text: str, checked: bool = False) -> str:
|
||||
"""Add an item at the end of the body.
|
||||
|
||||
|
||||
@@ -6,12 +6,9 @@ import pytest
|
||||
from inkwell import client_dist
|
||||
from inkwell.app import create_app
|
||||
from inkwell.client_dist import (
|
||||
APK_NAME,
|
||||
BY_ID,
|
||||
MANIFEST_NAME,
|
||||
PLATFORMS,
|
||||
advertisement,
|
||||
android_release,
|
||||
release,
|
||||
releases,
|
||||
)
|
||||
@@ -127,8 +124,8 @@ def test_the_android_names_are_the_ones_the_image_build_writes():
|
||||
Phones never ask for these names; they poll `/api/client/android`. The names
|
||||
changed once, with the rename to Inkwell (Scribe note 5071).
|
||||
"""
|
||||
assert APK_NAME == "inkwell.apk"
|
||||
assert MANIFEST_NAME == "inkwell-android.json"
|
||||
assert BY_ID["android"].artifact == "inkwell.apk"
|
||||
assert BY_ID["android"].sidecar == "inkwell-android.json"
|
||||
|
||||
|
||||
# --- absence is an ordinary answer -------------------------------------------
|
||||
@@ -242,7 +239,7 @@ def test_the_android_payload_still_carries_every_field_it_used_to():
|
||||
core/src/sync/client.rs is a plain serde struct and ignores what it does not
|
||||
know — but none of these may move or change meaning."""
|
||||
place("android")
|
||||
found = android_release()
|
||||
found = release("android")
|
||||
for key in ("version", "version_code", "size", "sha256", "url"):
|
||||
assert key in found, key
|
||||
assert found["url"] == "/api/client/android/download"
|
||||
@@ -299,7 +296,7 @@ def test_a_platform_the_server_lacks_is_simply_not_in_the_set():
|
||||
def test_the_baked_in_copy_is_used_when_nothing_was_dropped_in():
|
||||
"""The ordinary case for a self-hoster who just pulled the image."""
|
||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
||||
assert android_release()["version_code"] == 300
|
||||
assert release("android")["version_code"] == 300
|
||||
|
||||
|
||||
def test_a_dropped_in_build_beats_the_one_the_image_shipped():
|
||||
@@ -308,7 +305,7 @@ def test_a_dropped_in_build_beats_the_one_the_image_shipped():
|
||||
place("android", version_code=99)
|
||||
# Lower version and all — precedence is about intent, not about newness. An
|
||||
# operator pinning an older client is doing it on purpose.
|
||||
assert android_release()["version_code"] == 99
|
||||
assert release("android")["version_code"] == 99
|
||||
|
||||
|
||||
def test_precedence_is_decided_per_platform_not_for_the_whole_set():
|
||||
@@ -334,7 +331,7 @@ def test_a_broken_drop_in_does_not_shadow_the_baked_copy():
|
||||
"""
|
||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
||||
place("android", size=999_999) # sidecar describing a different build
|
||||
assert android_release()["version_code"] == 300
|
||||
assert release("android")["version_code"] == 300
|
||||
|
||||
|
||||
# --- the advertisement -------------------------------------------------------
|
||||
@@ -345,20 +342,8 @@ def test_the_advertisement_carries_the_whole_table():
|
||||
assert set(advertisement()["clients"]) == {"linux-deb"}
|
||||
|
||||
|
||||
def test_the_advertisement_still_carries_the_key_phones_already_read():
|
||||
"""Not deprecated in the change that introduces its replacement. An installed
|
||||
Android client reads `android_client`, and one duplicated dict is what it costs
|
||||
to not strand it."""
|
||||
place("android")
|
||||
data = advertisement()
|
||||
assert data["android_client"] == data["clients"]["android"]
|
||||
|
||||
|
||||
def test_no_android_client_means_no_android_key_even_when_others_are_present():
|
||||
place("windows")
|
||||
data = advertisement()
|
||||
assert "android_client" not in data
|
||||
assert set(data["clients"]) == {"windows"}
|
||||
def test_a_server_with_no_clients_advertises_nothing():
|
||||
assert advertisement() == {}
|
||||
|
||||
|
||||
# --- routes ------------------------------------------------------------------
|
||||
|
||||
+9
-17
@@ -9,14 +9,7 @@ from inkwell.app import create_app
|
||||
from inkwell.common import coerce_bool, parse_dt
|
||||
from inkwell.colors import NOTE_COLORS
|
||||
from inkwell.models.note import Note
|
||||
from inkwell.notes.checklist import (
|
||||
append_item,
|
||||
parse_items,
|
||||
remove_item,
|
||||
set_item_checked,
|
||||
set_item_text,
|
||||
strip_marker,
|
||||
)
|
||||
from inkwell.notes.checklist import append_item, parse_items, set_item_checked, strip_marker
|
||||
from inkwell.unfurl_queue import detect_urls
|
||||
from inkwell.notes import (
|
||||
_ImportBudget,
|
||||
@@ -58,13 +51,17 @@ def test_all_note_routes_registered(app):
|
||||
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
||||
"reorder_notes", "create_note",
|
||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||
"set_note_labels", "add_item", "update_item", "delete_item",
|
||||
"set_note_labels", "update_item",
|
||||
"upload_attachment", "get_attachment",
|
||||
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
|
||||
"delete_attachment", "delete_preview", "trash_note",
|
||||
"restore_note", "delete_note",
|
||||
)
|
||||
}
|
||||
assert expected <= registered, f"unregistered note routes: {expected - registered}"
|
||||
# Removed in #5178: an item is added, reworded and removed by editing the body,
|
||||
# and previews arrive in the background after a save.
|
||||
gone = {"notes.add_item", "notes.delete_item", "notes.unfurl_link"}
|
||||
assert not gone & registered, f"removed note routes are back: {gone & registered}"
|
||||
|
||||
|
||||
def test_is_empty_note():
|
||||
@@ -116,9 +113,9 @@ async def test_search_requires_auth(app):
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_add_item_requires_auth(app):
|
||||
async def test_update_item_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.post("/api/notes/00000000-0000-0000-0000-000000000000/items", json={"text": "x"})
|
||||
resp = await client.patch("/api/notes/00000000-0000-0000-0000-000000000000/items/0", json={"checked": True})
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@@ -583,8 +580,6 @@ def test_uppercase_x_parses_and_normalises_on_rewrite():
|
||||
|
||||
def test_rewriters_preserve_indent_bullet_and_neighbours():
|
||||
assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk"
|
||||
assert set_item_text("- [x] old", 0, "new") == "- [x] new"
|
||||
assert remove_item("keep\n- [ ] drop\n- [ ] stay", 0) == "keep\n- [ ] stay"
|
||||
# Addressed by ITEM, not by line.
|
||||
assert set_item_checked("note\n- [ ] a\nprose\n- [ ] b", 1, True) == "note\n- [ ] a\nprose\n- [x] b"
|
||||
|
||||
@@ -594,14 +589,11 @@ def test_a_stale_index_does_nothing():
|
||||
# should be inert, not a 500.
|
||||
body = "- [ ] only"
|
||||
assert set_item_checked(body, 7, True) == body
|
||||
assert remove_item(body, 7) == body
|
||||
assert set_item_text(body, 7, "x") == body
|
||||
|
||||
|
||||
def test_a_plain_body_is_returned_unchanged():
|
||||
body = "just prose\nwith two lines"
|
||||
assert set_item_checked(body, 0, True) == body
|
||||
assert remove_item(body, 0) == body
|
||||
|
||||
|
||||
def test_append_item_spacing():
|
||||
|
||||
@@ -58,14 +58,6 @@ def test_extract_preview_title_fallback_and_host_defaults():
|
||||
assert p["site_name"] == "example.com" # falls back to host
|
||||
|
||||
|
||||
async def test_unfurl_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.post(
|
||||
"/api/notes/00000000-0000-0000-0000-000000000000/unfurl", json={"url": "https://x.com"}
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
async def test_delete_preview_requires_auth(app):
|
||||
client = app.test_client()
|
||||
resp = await client.delete(
|
||||
|
||||
Reference in New Issue
Block a user