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_back">Back to notes</string>
|
||||||
<string name="editor_add_checklist">Add a checklist</string>
|
<string name="editor_add_checklist">Add a checklist</string>
|
||||||
<string name="editor_body_hint">Take a note…</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_item">Remove item</string>
|
||||||
<string name="editor_remove_label">Remove tag</string>
|
<string name="editor_remove_label">Remove tag</string>
|
||||||
<string name="editor_reminder">Set a reminder</string>
|
<string name="editor_reminder">Set a reminder</string>
|
||||||
|
|||||||
+24
-87
@@ -224,53 +224,29 @@ impl Inkwell {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ──────────────────────────── checklist items ────────────────────────────
|
// ──────────────────────────── 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> {
|
/// Tick or untick one item. Adding, rewording and removing items are edits to
|
||||||
let conn = self.db.conn().map_err(CoreError::store)?;
|
/// the body, which the editor makes like any other.
|
||||||
local::store::add_item(&conn, ¬e_id, &text)
|
|
||||||
.map(Note::from)
|
|
||||||
.map_err(CoreError::store)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Retitle one item.
|
|
||||||
///
|
///
|
||||||
/// Split from `set_item_checked` rather than exposing the core's
|
/// Returns the whole reloaded note rather than the item it touched. That is the
|
||||||
/// `{text?, checked?}` patch, for the same reason `NoteEdit` exists: an
|
/// core's shape, and it is the right one for a UI: ticking a box changes
|
||||||
/// optional-field struct cannot say "leave this alone" in Kotlin without
|
/// `updated_at` and can change what the board shows, so handing back only the
|
||||||
/// colliding with "set it to null", and two unambiguous calls beat one
|
/// item would leave Kotlin to guess at the rest.
|
||||||
/// 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 }))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn set_item_checked(
|
pub fn set_item_checked(
|
||||||
&self,
|
&self,
|
||||||
note_id: String,
|
note_id: String,
|
||||||
item_id: String,
|
item_id: String,
|
||||||
checked: bool,
|
checked: bool,
|
||||||
) -> Result<Note, CoreError> {
|
) -> Result<Note, CoreError> {
|
||||||
self.patch_item(
|
let conn = self.db.conn().map_err(CoreError::store)?;
|
||||||
|
local::store::update_item(
|
||||||
|
&conn,
|
||||||
¬e_id,
|
¬e_id,
|
||||||
&item_id,
|
&item_id,
|
||||||
serde_json::json!({ "checked": checked }),
|
&serde_json::json!({ "checked": checked }),
|
||||||
)
|
)
|
||||||
}
|
.map(Note::from)
|
||||||
|
.map_err(CoreError::store)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─────────────────────────────── reminders ───────────────────────────────
|
// ─────────────────────────────── reminders ───────────────────────────────
|
||||||
@@ -721,22 +697,6 @@ pub fn body_tags(body: String) -> Vec<BodyTag> {
|
|||||||
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
/// Helpers, deliberately NOT exported — uniffi only binds what an `#[uniffi::export]`
|
||||||
/// block names, so these stay Rust-side.
|
/// block names, so these stay Rust-side.
|
||||||
impl Inkwell {
|
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
|
/// The server URL + token, or the `NotLinked` state. Every networked call needs
|
||||||
/// exactly this, and none of them may hold the lock past it.
|
/// exactly this, and none of them may hold the lock past it.
|
||||||
fn credentials(&self) -> Result<(String, String), CoreError> {
|
fn credentials(&self) -> Result<(String, String), CoreError> {
|
||||||
@@ -868,55 +828,32 @@ mod tests {
|
|||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The editor's whole checklist loop, in one pass: add a row, tick it, retitle
|
/// Ticking a box returns the reloaded note, which is what the UI splices back
|
||||||
/// it, drop it. Each call returns the reloaded note, which is what the UI
|
/// into the board rather than re-querying, and leaves the item's text alone.
|
||||||
/// splices back into the board rather than re-querying.
|
|
||||||
#[test]
|
#[test]
|
||||||
fn checklist_items_can_be_added_ticked_retitled_and_removed() {
|
fn ticking_an_item_rewrites_only_its_box() {
|
||||||
let dir = scratch_dir();
|
let dir = scratch_dir();
|
||||||
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
let app = Inkwell::new(dir.clone()).expect("a fresh data dir should open");
|
||||||
let note = app
|
let note = app
|
||||||
.create_note(NoteDraft {
|
.create_note(NoteDraft {
|
||||||
body: "Packing".to_string(),
|
body: "Packing".to_string(),
|
||||||
items: Some(vec!["socks".to_string()]),
|
items: Some(vec!["socks".to_string(), "charger".to_string()]),
|
||||||
})
|
})
|
||||||
.expect("create");
|
.expect("create");
|
||||||
assert_eq!(note.items.len(), 1);
|
assert_eq!(note.items.len(), 2);
|
||||||
|
|
||||||
let with_two = app
|
let item_id = note.items[1].id.clone();
|
||||||
.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 ticked = app
|
let ticked = app
|
||||||
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
.set_item_checked(note.id.clone(), item_id.clone(), true)
|
||||||
.expect("tick");
|
.expect("tick");
|
||||||
assert!(ticked.items[1].checked);
|
assert!(ticked.items[1].checked);
|
||||||
assert_eq!(
|
assert!(!ticked.items[0].checked);
|
||||||
ticked.items[1].text, "charger",
|
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"
|
|
||||||
);
|
|
||||||
|
|
||||||
let renamed = app
|
let unticked = app
|
||||||
.set_item_text(note.id.clone(), item_id.clone(), "usb-c cable".to_string())
|
.set_item_checked(note.id.clone(), item_id, false)
|
||||||
.expect("rename");
|
.expect("untick");
|
||||||
assert_eq!(renamed.items[1].text, "usb-c cable");
|
assert_eq!(unticked.body, note.body);
|
||||||
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");
|
|
||||||
|
|
||||||
std::fs::remove_dir_all(&dir).ok();
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-109
@@ -52,22 +52,6 @@ fn line_tags(chars: &[char]) -> Vec<(usize, usize, String)> {
|
|||||||
out
|
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.
|
/// 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
|
/// 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,
|
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.
|
/// keeping the spans instead of throwing them away.
|
||||||
///
|
///
|
||||||
/// Not deduped, unlike `extract_tags`: two mentions of `#todo` are two pieces of text
|
/// Not deduped: two mentions of `#todo` are two pieces of text to colour. Fences are
|
||||||
/// to colour. Fences are not skipped either, and that is deliberate — `extract_tags`
|
/// not skipped either, and that is deliberate — a `#tag` inside a code block stays an
|
||||||
/// does not skip them, so a `#tag` inside a code block IS a label on the note, and a
|
/// inline label on the note, and a renderer that left it plain would be the only
|
||||||
/// renderer that left it plain would be the only surface disagreeing.
|
/// surface disagreeing.
|
||||||
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
pub fn extract_tag_spans(body: &str) -> Vec<DerivedTag> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for (n, line) in body.split('\n').enumerate() {
|
for (n, line) in body.split('\n').enumerate() {
|
||||||
@@ -383,65 +367,29 @@ pub fn extract_items(body: &str) -> Vec<DerivedItem> {
|
|||||||
out
|
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
|
/// 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
|
/// 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.
|
/// 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 {
|
pub fn set_item_checked(body: &str, index: usize, checked: bool) -> String {
|
||||||
map_task_line(body, index, |t| {
|
let mut lines: Vec<String> = body.split('\n').map(str::to_string).collect();
|
||||||
Some(render_task_line(t.indent, t.bullet, checked, t.text))
|
let Some(line) = lines
|
||||||
})
|
.iter_mut()
|
||||||
}
|
.filter(|l| parse_task_line(l.as_str()).is_some())
|
||||||
|
.nth(index)
|
||||||
/// Replace the text of the `index`-th item, keeping its state and its bullet.
|
else {
|
||||||
pub fn set_item_text(body: &str, index: usize, text: &str) -> String {
|
return body.to_string();
|
||||||
map_task_line(body, index, |t| {
|
};
|
||||||
Some(render_task_line(t.indent, t.bullet, t.checked, text.trim()))
|
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);
|
||||||
/// Delete the `index`-th item, line and all.
|
*line = ticked;
|
||||||
pub fn remove_item(body: &str, index: usize) -> String {
|
lines.join("\n")
|
||||||
map_task_line(body, index, |_| None)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add an item at the end of the body.
|
/// 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 {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn tags_basic() {
|
fn tags_basic() {
|
||||||
assert_eq!(
|
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"]
|
vec!["todo", "Work-item_2"]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -487,17 +444,12 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn tags_require_letter_start_and_boundary() {
|
fn tags_require_letter_start_and_boundary() {
|
||||||
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
// "#1" (digit) and an in-word "#" (email-ish) are not tags.
|
||||||
assert_eq!(extract_tags("#1 nope a#b no but #Yes"), vec!["Yes"]);
|
assert_eq!(tag_names("#1 nope a#b no but #Yes ##no"), vec!["Yes"]);
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn tags_dedupe_case_insensitive() {
|
|
||||||
assert_eq!(extract_tags("#Home #home #HOME"), vec!["Home"]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn empty_body() {
|
fn empty_body() {
|
||||||
assert!(extract_tags("").is_empty());
|
assert!(extract_tag_spans("").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── tag spans, for the renderer that draws them in place ─────────────────
|
// ── 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));
|
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 ──────────────────────────────────────────────
|
// ── lifting standalone tags ──────────────────────────────────────────────
|
||||||
//
|
//
|
||||||
// The MIRROR of `split_body_tags` in the server's notes/tags.py, case for case.
|
// 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]
|
#[test]
|
||||||
fn append_spaces_like_the_exporter() {
|
fn append_spaces_like_the_exporter() {
|
||||||
// Prose then a blank line then the list — byte-for-byte what
|
// 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.
|
// that arrives late should be inert, not fatal.
|
||||||
let body = "- [ ] only";
|
let body = "- [ ] only";
|
||||||
assert_eq!(set_item_checked(body, 7, true), body);
|
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]
|
#[test]
|
||||||
fn a_plain_body_is_returned_byte_identical() {
|
fn a_plain_body_is_returned_byte_identical() {
|
||||||
let body = "just prose\nwith two lines";
|
let body = "just prose\nwith two lines";
|
||||||
assert_eq!(set_item_checked(body, 0, true), body);
|
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]
|
#[test]
|
||||||
@@ -828,7 +755,7 @@ mod tests {
|
|||||||
fn fixture_tags() {
|
fn fixture_tags() {
|
||||||
for case in fixture()["tags"].as_array().unwrap() {
|
for case in fixture()["tags"].as_array().unwrap() {
|
||||||
let body = case["body"].as_str().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 id: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
pub display_name: String,
|
pub display_name: String,
|
||||||
pub email_verified: bool,
|
|
||||||
pub is_admin: 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)
|
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
|
// It keeps the item's name and signature because the FFI, the Tauri commands and the
|
||||||
// the REST shape all speak in items, and a checklist is still a thing a note HAS.
|
// REST shape all speak in items, and a checklist is still a thing a note HAS. Routing
|
||||||
// What changed is where it is kept. Routing all three through `update_note` rather
|
// it through `update_note` rather than writing the body directly is what gives it
|
||||||
// than writing the body directly is what gives them revision snapshotting, `#tag`
|
// revision snapshotting, `#tag` re-derivation and the dirty/updated_at bookkeeping
|
||||||
// re-derivation and the dirty/updated_at bookkeeping without any of it being
|
// without any of it being written a second time here.
|
||||||
// written a second time here.
|
|
||||||
|
|
||||||
fn note_body(conn: &Connection, id: &str) -> rusqlite::Result<String> {
|
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))
|
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 }))
|
update_note(conn, id, &json!({ "body": body }))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn add_item(conn: &Connection, id: &str, text: &str) -> rusqlite::Result<Note> {
|
/// Tick or untick one item. The only item change that isn't an edit to the body's
|
||||||
let body = note_body(conn, id)?;
|
/// text: adding, rewording and removing an item happen in the editor.
|
||||||
set_body(conn, id, derive::append_item(&body, text, false))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn update_item(
|
pub fn update_item(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
id: &str,
|
id: &str,
|
||||||
@@ -795,24 +791,12 @@ pub fn update_item(
|
|||||||
None => return load_note(conn, id),
|
None => return load_note(conn, id),
|
||||||
};
|
};
|
||||||
let mut body = note_body(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) {
|
if let Some(checked) = changes.get("checked").and_then(Value::as_bool) {
|
||||||
body = derive::set_item_checked(&body, index, checked);
|
body = derive::set_item_checked(&body, index, checked);
|
||||||
}
|
}
|
||||||
set_body(conn, id, body)
|
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.
|
/// 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.
|
/// Matches the server's `normalize_mime`, so both sides file a type the same way.
|
||||||
fn normalize_mime(raw: &str) -> String {
|
fn normalize_mime(raw: &str) -> String {
|
||||||
@@ -1355,7 +1339,7 @@ mod tests {
|
|||||||
.expect("refused");
|
.expect("refused");
|
||||||
// The words the person sees, exactly: the refusal prints as its message.
|
// The words the person sees, exactly: the refusal prints as its message.
|
||||||
assert_eq!(refused.to_string(), VIEW_ONLY);
|
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!(trash(&conn, "n").is_err());
|
||||||
assert!(snooze_reminder(&conn, "n", 10).is_err());
|
assert!(snooze_reminder(&conn, "n", 10).is_err());
|
||||||
assert!(set_labels(&conn, "n", &[]).is_err());
|
assert!(set_labels(&conn, "n", &[]).is_err());
|
||||||
@@ -1374,7 +1358,7 @@ mod tests {
|
|||||||
assert!(edited.labels.is_empty());
|
assert!(edited.labels.is_empty());
|
||||||
assert_eq!(edited.body, "their words, edited\n#mine");
|
assert_eq!(edited.body, "their words, edited\n#mine");
|
||||||
assert!(dirty(&conn, "n"));
|
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(
|
let reminded = update_note(
|
||||||
&conn,
|
&conn,
|
||||||
|
|||||||
@@ -104,8 +104,9 @@ impl BlobStore {
|
|||||||
// store. The webview then caches and range-requests them like any other resource,
|
// store. The webview then caches and range-requests them like any other resource,
|
||||||
// which a `data:` URI would have thrown away.
|
// which a `data:` URI would have thrown away.
|
||||||
|
|
||||||
/// The scheme the webview fetches attachment bytes over.
|
/// The scheme the webview fetches attachment bytes over. Nothing stores a URL built
|
||||||
pub const BLOB_SCHEME: &str = "tsblob";
|
/// 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.
|
/// 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,
|
// 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.
|
// so at least pin that the right branch was taken for THIS build.
|
||||||
if cfg!(any(windows, target_os = "android")) {
|
if cfg!(any(windows, target_os = "android")) {
|
||||||
assert!(url.starts_with("http://tsblob.localhost/"), "{url}");
|
assert!(url.starts_with("http://inkblob.localhost/"), "{url}");
|
||||||
} else {
|
} 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(),
|
id: "local".to_string(),
|
||||||
email: "local@inkwell.app".to_string(),
|
email: "local@inkwell.app".to_string(),
|
||||||
display_name: "You".to_string(),
|
display_name: "You".to_string(),
|
||||||
email_verified: true,
|
|
||||||
is_admin: false,
|
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())
|
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]
|
#[tauri::command]
|
||||||
pub fn notes_update_item(
|
pub fn notes_update_item(
|
||||||
id: String,
|
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())
|
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).
|
/// 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
|
/// 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_complete_reminder,
|
||||||
commands::local::notes_snooze_reminder,
|
commands::local::notes_snooze_reminder,
|
||||||
commands::local::notes_set_labels,
|
commands::local::notes_set_labels,
|
||||||
commands::local::notes_add_item,
|
|
||||||
commands::local::notes_update_item,
|
commands::local::notes_update_item,
|
||||||
commands::local::notes_delete_item,
|
|
||||||
commands::local::notes_add_attachment,
|
commands::local::notes_add_attachment,
|
||||||
commands::local::notes_export,
|
commands::local::notes_export,
|
||||||
commands::local::notes_import,
|
commands::local::notes_import,
|
||||||
|
|||||||
@@ -135,8 +135,10 @@ address can be sent.
|
|||||||
|
|
||||||
Know these before you decide who gets an account.
|
Know these before you decide who gets an account.
|
||||||
|
|
||||||
- **No email verification.** `email_verified` exists on the user row and nothing sets
|
- **No email verification.** An account's address is whatever was typed when it
|
||||||
it. Email is used only for password resets (above).
|
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,
|
- **An admin who forgets their own password**, with email off and no other admin,
|
||||||
still needs a hand on the database.
|
still needs a hand on the database.
|
||||||
- **No second factor.** A password is the whole of it.
|
- **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
|
// 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
|
// 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,
|
// rather than silently failing; the board, editor, attachments, capture, filters,
|
||||||
// labels, checklists, reminders, import and export all work fully offline.
|
// 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 }),
|
completeReminder: (id) => invoke<Note>("notes_complete_reminder", { id }),
|
||||||
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
snoozeReminder: (id, minutes) => invoke<Note>("notes_snooze_reminder", { id, minutes }),
|
||||||
setLabels: (id, labelIds) => invoke<Note>("notes_set_labels", { id, labelIds }),
|
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 }),
|
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
|
// 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
|
// go as the raw IPC body; the name is percent-encoded because a header carries
|
||||||
// only ASCII.
|
// only ASCII.
|
||||||
@@ -70,7 +68,6 @@ export const local: Repo = {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
deleteAttachment: (id, attId) => invoke<Note>("notes_delete_attachment", { id, attId }),
|
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 }),
|
deletePreview: (id, previewId) => invoke<Note>("notes_delete_preview", { id, previewId }),
|
||||||
// The archive crosses as raw bytes, as an attachment does.
|
// The archive crosses as raw bytes, as an attachment does.
|
||||||
import: async (file) => invoke<ImportResult>("notes_import", new Uint8Array(await file.arrayBuffer())),
|
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">
|
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 {
|
export interface ChecklistItemChanges {
|
||||||
text?: string;
|
checked: boolean;
|
||||||
checked?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -131,12 +131,9 @@ export interface NotesRepo {
|
|||||||
completeReminder(id: string): Promise<Note>;
|
completeReminder(id: string): Promise<Note>;
|
||||||
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
snoozeReminder(id: string, minutes: number): Promise<Note>;
|
||||||
setLabels(id: string, labelIds: string[]): 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>;
|
updateItem(id: string, itemId: string, changes: ChecklistItemChanges): Promise<Note>;
|
||||||
deleteItem(id: string, itemId: string): Promise<Note>;
|
|
||||||
uploadAttachment(id: string, file: File): Promise<Note>;
|
uploadAttachment(id: string, file: File): Promise<Note>;
|
||||||
deleteAttachment(id: string, attId: string): Promise<Note>;
|
deleteAttachment(id: string, attId: string): Promise<Note>;
|
||||||
unfurl(id: string, url: string): Promise<Note>;
|
|
||||||
deletePreview(id: string, previewId: string): Promise<Note>;
|
deletePreview(id: string, previewId: string): Promise<Note>;
|
||||||
import(file: File): Promise<ImportResult>;
|
import(file: File): Promise<ImportResult>;
|
||||||
/** Save every note as a zip. Resolves to the path it was saved at, or null when
|
/** 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`),
|
completeReminder: (id) => api.post<Note>(`/api/notes/${id}/reminder/complete`),
|
||||||
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
snoozeReminder: (id, minutes) => api.post<Note>(`/api/notes/${id}/reminder/snooze`, { minutes }),
|
||||||
setLabels: (id, labelIds) => api.put<Note>(`/api/notes/${id}/labels`, { label_ids: labelIds }),
|
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) =>
|
updateItem: (id, itemId, changes: ChecklistItemChanges) =>
|
||||||
api.patch<Note>(`/api/notes/${id}/items/${itemId}`, changes),
|
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)),
|
uploadAttachment: (id, file) => api.postForm<Note>(`/api/notes/${id}/attachments`, fileForm(file)),
|
||||||
deleteAttachment: (id, attId) => api.del<Note>(`/api/notes/${id}/attachments/${attId}`),
|
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}`),
|
deletePreview: (id, previewId) => api.del<Note>(`/api/notes/${id}/previews/${previewId}`),
|
||||||
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
|
import: (file) => api.postForm<ImportResult>("/api/notes/import", fileForm(file)),
|
||||||
// A same-origin GET with the session cookie; the browser saves the attachment
|
// 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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
checkbox: '<rect width="18" height="18" x="3" y="3" rx="2"/><path d="m9 12 2 2 4-4"/>',
|
||||||
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
image: '<rect width="18" height="18" x="3" y="3" rx="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
|
||||||
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
bell: '<path d="M10.268 21a2 2 0 0 0 3.464 0"/><path d="M3.262 15.326A1 1 0 0 0 4 17h16a1 1 0 0 0 .74-1.673C19.41 13.956 18 12.499 18 8A6 6 0 0 0 6 8c0 4.499-1.411 5.956-2.738 7.326"/>',
|
||||||
@@ -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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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"/>',
|
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
|
// 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
|
// 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.
|
// 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>;
|
clients?: Record<string, ClientRelease>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -184,18 +184,10 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
reconcile(await repo.notes.setLabels(id, labelIds));
|
reconcile(await repo.notes.setLabels(id, labelIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addItem(id: string, text: string): Promise<void> {
|
async function updateItem(id: string, itemId: string, changes: { checked: boolean }): Promise<void> {
|
||||||
reconcile(await repo.notes.addItem(id, text));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function updateItem(id: string, itemId: string, changes: { text?: string; checked?: boolean }): Promise<void> {
|
|
||||||
reconcile(await repo.notes.updateItem(id, itemId, changes));
|
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> {
|
async function uploadAttachment(id: string, file: File): Promise<void> {
|
||||||
reconcile(await repo.notes.uploadAttachment(id, file));
|
reconcile(await repo.notes.uploadAttachment(id, file));
|
||||||
}
|
}
|
||||||
@@ -204,10 +196,6 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
reconcile(await repo.notes.deleteAttachment(id, attId));
|
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> {
|
async function deletePreview(id: string, previewId: string): Promise<void> {
|
||||||
reconcile(await repo.notes.deletePreview(id, previewId));
|
reconcile(await repo.notes.deletePreview(id, previewId));
|
||||||
}
|
}
|
||||||
@@ -299,12 +287,9 @@ export const useNotesStore = defineStore("notes", () => {
|
|||||||
snoozeReminder,
|
snoozeReminder,
|
||||||
saveEdit,
|
saveEdit,
|
||||||
setLabels,
|
setLabels,
|
||||||
addItem,
|
|
||||||
updateItem,
|
updateItem,
|
||||||
deleteItem,
|
|
||||||
uploadAttachment,
|
uploadAttachment,
|
||||||
deleteAttachment,
|
deleteAttachment,
|
||||||
unfurl,
|
|
||||||
deletePreview,
|
deletePreview,
|
||||||
importNotes,
|
importNotes,
|
||||||
fetchOne,
|
fetchOne,
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ export interface User {
|
|||||||
id: string;
|
id: string;
|
||||||
email: string;
|
email: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
email_verified: boolean;
|
|
||||||
is_admin: 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
|
# 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.
|
# whether it can talk to this server, and which optional features to offer.
|
||||||
data.update(protocol_advertisement())
|
data.update(protocol_advertisement())
|
||||||
# Which CLIENTS this server can hand out, if any — the whole set under
|
# Which CLIENTS this server can hand out, if any, under `clients`. Absent
|
||||||
# `clients`, plus the older `android_client` key that phones in the field
|
# rather than null when it has none, so the web UI hides a download instead
|
||||||
# still read. Absent rather than null when it has none, so the web UI hides
|
# of offering a button that 404s.
|
||||||
# a download instead of offering a button that 404s.
|
|
||||||
data.update(client_advertisement())
|
data.update(client_advertisement())
|
||||||
return jsonify(data)
|
return jsonify(data)
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ def _serialize_user(user: User) -> dict:
|
|||||||
"id": str(user.id),
|
"id": str(user.id),
|
||||||
"email": user.email,
|
"email": user.email,
|
||||||
"display_name": user.display_name,
|
"display_name": user.display_name,
|
||||||
"email_verified": user.email_verified,
|
|
||||||
"is_admin": user.is_admin,
|
"is_admin": user.is_admin,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -176,12 +176,6 @@ PLATFORMS: tuple[Platform, ...] = (
|
|||||||
|
|
||||||
BY_ID: dict[str, Platform] = {p.id: p for p in PLATFORMS}
|
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
|
# 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.
|
# is a volume mount, and a file the image wrote there would vanish behind it.
|
||||||
BAKED_ROOT = Path(__file__).resolve().parent / "client"
|
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}
|
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:
|
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:
|
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
|
||||||
`clients` is the whole table, which is what the web UI renders the downloads
|
"an old server that never had this field".
|
||||||
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".
|
|
||||||
"""
|
"""
|
||||||
data: dict = {}
|
|
||||||
found = releases()
|
found = releases()
|
||||||
if found:
|
return {"clients": found} if found else {}
|
||||||
data["clients"] = found
|
|
||||||
if "android" in found:
|
|
||||||
data["android_client"] = found["android"]
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
@bp.get("/api/client")
|
@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)
|
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.
|
# CITEXT so lookups/uniqueness are case-insensitive without lower() everywhere.
|
||||||
email: Mapped[str] = mapped_column(CITEXT(), nullable=False, unique=True)
|
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.
|
# First registered user becomes admin (see auth.register); admin gates Settings.
|
||||||
is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
is_admin: Mapped[bool] = mapped_column(Boolean(), nullable=False, server_default=func.false())
|
||||||
# Nullable: leaves room for external-identity-only accounts later (rule 26).
|
# 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
|
# 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.
|
# 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")
|
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())
|
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())
|
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_link_preview import NoteLinkPreview
|
||||||
from ..models.note_revision import NoteRevision
|
from ..models.note_revision import NoteRevision
|
||||||
from ..note_state import OWN_FIELDS, archived_for, join_state, pinned_for, position_for, set_own_state
|
from ..note_state import OWN_FIELDS, archived_for, join_state, pinned_for, position_for, set_own_state
|
||||||
from .checklist import (
|
from .checklist import append_item, parse_items, set_item_checked
|
||||||
append_item,
|
|
||||||
parse_items,
|
|
||||||
remove_item,
|
|
||||||
set_item_checked,
|
|
||||||
set_item_text,
|
|
||||||
)
|
|
||||||
from ..responses import json_error, not_found, parse_uuid
|
from ..responses import json_error, not_found, parse_uuid
|
||||||
from ..retention import purge_note
|
from ..retention import purge_note
|
||||||
from ..settings import get_setting
|
from ..settings import get_setting
|
||||||
from ..unfurl_queue import schedule as schedule_unfurls
|
from ..unfurl_queue import schedule as schedule_unfurls
|
||||||
from ..unfurl import UnfurlError, unfurl
|
|
||||||
from ._bp import bp
|
from ._bp import bp
|
||||||
from .body import write_body
|
from .body import write_body
|
||||||
from .helpers import (
|
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))
|
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>")
|
@bp.patch("/<note_id>/items/<item_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def update_item(note_id: str, item_id: str):
|
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 {}
|
data = await request.get_json(silent=True) or {}
|
||||||
index = _item_index(item_id)
|
index = _item_index(item_id)
|
||||||
if index is None:
|
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)):
|
if index >= len(parse_items(note.body)):
|
||||||
return not_found()
|
return not_found()
|
||||||
body = note.body
|
body = note.body
|
||||||
if "text" in data and isinstance(data["text"], str):
|
|
||||||
body = set_item_text(body, index, data["text"])
|
|
||||||
if "checked" in data:
|
if "checked" in data:
|
||||||
body = set_item_checked(body, index, bool(data["checked"]))
|
body = set_item_checked(body, index, bool(data["checked"]))
|
||||||
return await _rewrite_body(db, note, body)
|
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
|
# 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
|
# 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.
|
# 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)
|
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>")
|
@bp.delete("/<note_id>/previews/<preview_id>")
|
||||||
@login_required
|
@login_required
|
||||||
async def delete_preview(note_id: str, preview_id: str):
|
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
|
return (match.group("text") or "") if match else line
|
||||||
|
|
||||||
|
|
||||||
def _rewrite(body: str, index: int, replace) -> str:
|
def set_item_checked(body: str, index: int, checked: bool) -> str:
|
||||||
"""Rewrite the `index`-th task line with `replace`, or drop it when `replace`
|
"""Tick or untick the `index`-th item, keeping its text, indent and bullet. The
|
||||||
returns None.
|
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:
|
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
|
the index comes from a client that may be a moment behind the server, and a stale
|
||||||
request should do nothing rather than 500.
|
request should do nothing rather than 500.
|
||||||
"""
|
"""
|
||||||
lines = body.split("\n")
|
lines = body.split("\n")
|
||||||
target = None
|
tasks = [n for n, line in enumerate(lines) if _TASK_RE.match(line)]
|
||||||
seen = 0
|
if index >= len(tasks):
|
||||||
for n, line in enumerate(lines):
|
|
||||||
if _TASK_RE.match(line):
|
|
||||||
if seen == index:
|
|
||||||
target = n
|
|
||||||
break
|
|
||||||
seen += 1
|
|
||||||
if target is None:
|
|
||||||
return body
|
return body
|
||||||
|
target = tasks[index]
|
||||||
match = _TASK_RE.match(lines[target])
|
m = _TASK_RE.match(lines[target])
|
||||||
replacement = replace(match)
|
lines[target] = render_item(m.group("text") or "", checked, m.group("indent"), m.group("bullet"))
|
||||||
if replacement is None:
|
|
||||||
del lines[target]
|
|
||||||
else:
|
|
||||||
lines[target] = replacement
|
|
||||||
return "\n".join(lines)
|
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:
|
def append_item(body: str, text: str, checked: bool = False) -> str:
|
||||||
"""Add an item at the end of the body.
|
"""Add an item at the end of the body.
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,9 @@ import pytest
|
|||||||
from inkwell import client_dist
|
from inkwell import client_dist
|
||||||
from inkwell.app import create_app
|
from inkwell.app import create_app
|
||||||
from inkwell.client_dist import (
|
from inkwell.client_dist import (
|
||||||
APK_NAME,
|
|
||||||
BY_ID,
|
BY_ID,
|
||||||
MANIFEST_NAME,
|
|
||||||
PLATFORMS,
|
PLATFORMS,
|
||||||
advertisement,
|
advertisement,
|
||||||
android_release,
|
|
||||||
release,
|
release,
|
||||||
releases,
|
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
|
Phones never ask for these names; they poll `/api/client/android`. The names
|
||||||
changed once, with the rename to Inkwell (Scribe note 5071).
|
changed once, with the rename to Inkwell (Scribe note 5071).
|
||||||
"""
|
"""
|
||||||
assert APK_NAME == "inkwell.apk"
|
assert BY_ID["android"].artifact == "inkwell.apk"
|
||||||
assert MANIFEST_NAME == "inkwell-android.json"
|
assert BY_ID["android"].sidecar == "inkwell-android.json"
|
||||||
|
|
||||||
|
|
||||||
# --- absence is an ordinary answer -------------------------------------------
|
# --- 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
|
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."""
|
know — but none of these may move or change meaning."""
|
||||||
place("android")
|
place("android")
|
||||||
found = android_release()
|
found = release("android")
|
||||||
for key in ("version", "version_code", "size", "sha256", "url"):
|
for key in ("version", "version_code", "size", "sha256", "url"):
|
||||||
assert key in found, key
|
assert key in found, key
|
||||||
assert found["url"] == "/api/client/android/download"
|
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():
|
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."""
|
"""The ordinary case for a self-hoster who just pulled the image."""
|
||||||
place("android", root=client_dist.BAKED_ROOT, version_code=300)
|
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():
|
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)
|
place("android", version_code=99)
|
||||||
# Lower version and all — precedence is about intent, not about newness. An
|
# Lower version and all — precedence is about intent, not about newness. An
|
||||||
# operator pinning an older client is doing it on purpose.
|
# 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():
|
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", root=client_dist.BAKED_ROOT, version_code=300)
|
||||||
place("android", size=999_999) # sidecar describing a different build
|
place("android", size=999_999) # sidecar describing a different build
|
||||||
assert android_release()["version_code"] == 300
|
assert release("android")["version_code"] == 300
|
||||||
|
|
||||||
|
|
||||||
# --- the advertisement -------------------------------------------------------
|
# --- the advertisement -------------------------------------------------------
|
||||||
@@ -345,20 +342,8 @@ def test_the_advertisement_carries_the_whole_table():
|
|||||||
assert set(advertisement()["clients"]) == {"linux-deb"}
|
assert set(advertisement()["clients"]) == {"linux-deb"}
|
||||||
|
|
||||||
|
|
||||||
def test_the_advertisement_still_carries_the_key_phones_already_read():
|
def test_a_server_with_no_clients_advertises_nothing():
|
||||||
"""Not deprecated in the change that introduces its replacement. An installed
|
assert advertisement() == {}
|
||||||
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"}
|
|
||||||
|
|
||||||
|
|
||||||
# --- routes ------------------------------------------------------------------
|
# --- routes ------------------------------------------------------------------
|
||||||
|
|||||||
+9
-17
@@ -9,14 +9,7 @@ from inkwell.app import create_app
|
|||||||
from inkwell.common import coerce_bool, parse_dt
|
from inkwell.common import coerce_bool, parse_dt
|
||||||
from inkwell.colors import NOTE_COLORS
|
from inkwell.colors import NOTE_COLORS
|
||||||
from inkwell.models.note import Note
|
from inkwell.models.note import Note
|
||||||
from inkwell.notes.checklist import (
|
from inkwell.notes.checklist import append_item, parse_items, set_item_checked, strip_marker
|
||||||
append_item,
|
|
||||||
parse_items,
|
|
||||||
remove_item,
|
|
||||||
set_item_checked,
|
|
||||||
set_item_text,
|
|
||||||
strip_marker,
|
|
||||||
)
|
|
||||||
from inkwell.unfurl_queue import detect_urls
|
from inkwell.unfurl_queue import detect_urls
|
||||||
from inkwell.notes import (
|
from inkwell.notes import (
|
||||||
_ImportBudget,
|
_ImportBudget,
|
||||||
@@ -58,13 +51,17 @@ def test_all_note_routes_registered(app):
|
|||||||
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
"snooze_reminder", "export_notes", "import_notes", "list_titles",
|
||||||
"reorder_notes", "create_note",
|
"reorder_notes", "create_note",
|
||||||
"get_note", "update_note", "list_revisions", "restore_revision",
|
"get_note", "update_note", "list_revisions", "restore_revision",
|
||||||
"set_note_labels", "add_item", "update_item", "delete_item",
|
"set_note_labels", "update_item",
|
||||||
"upload_attachment", "get_attachment",
|
"upload_attachment", "get_attachment",
|
||||||
"delete_attachment", "unfurl_link", "delete_preview", "trash_note",
|
"delete_attachment", "delete_preview", "trash_note",
|
||||||
"restore_note", "delete_note",
|
"restore_note", "delete_note",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
assert expected <= registered, f"unregistered note routes: {expected - registered}"
|
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():
|
def test_is_empty_note():
|
||||||
@@ -116,9 +113,9 @@ async def test_search_requires_auth(app):
|
|||||||
assert resp.status_code == 401
|
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()
|
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
|
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():
|
def test_rewriters_preserve_indent_bullet_and_neighbours():
|
||||||
assert set_item_checked(" * [ ] milk", 0, True) == " * [x] milk"
|
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.
|
# 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"
|
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.
|
# should be inert, not a 500.
|
||||||
body = "- [ ] only"
|
body = "- [ ] only"
|
||||||
assert set_item_checked(body, 7, True) == body
|
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():
|
def test_a_plain_body_is_returned_unchanged():
|
||||||
body = "just prose\nwith two lines"
|
body = "just prose\nwith two lines"
|
||||||
assert set_item_checked(body, 0, True) == body
|
assert set_item_checked(body, 0, True) == body
|
||||||
assert remove_item(body, 0) == body
|
|
||||||
|
|
||||||
|
|
||||||
def test_append_item_spacing():
|
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
|
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):
|
async def test_delete_preview_requires_auth(app):
|
||||||
client = app.test_client()
|
client = app.test_client()
|
||||||
resp = await client.delete(
|
resp = await client.delete(
|
||||||
|
|||||||
Reference in New Issue
Block a user