diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 11491fc..5bd2fc8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -244,3 +244,9 @@ version = "1.0.0" "journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." "journals:label.editor" = "Editor" "journals:label.preview_pane" = "Preview" +"journals:action.edit_tags" = "Edit tags" +"journals:action.tags" = "Tags" +"journals:label.directories" = "Directories" +"journals:action.create_subdir" = "Create subdirectory" +"journals:action.create_root_dir" = "Create root directory" +"journals:action.move" = "Move" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 0603ee1..5e2094b 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -1065,14 +1065,14 @@ details summary::-webkit-details-marker { display: none; } -details[open] summary { +details[open] > summary { position: relative; - color: var(--color-primary); - background: var(--color-super-lowered); + color: var(--color-text-lowered) !important; + background: var(--color-super-lowered) !important; margin-bottom: var(--pad-1); } -details[open] summary::after { +details[open] > summary::after { top: 0; left: 0; width: 5px; diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 251359d..c7c36d3 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2028,6 +2028,22 @@ (str (text "general:action.delete"))))) (text "{%- endif %}")) +(text "{% if selected_note -%}") +; open all details elements above the selected note +(script + ("defer" "true") + (text "setTimeout(() => { + let cursor = document.querySelector(\"[ui_ident=active_note]\"); + while (cursor) { + if (cursor.nodeName === \"DETAILS\") { + cursor.setAttribute(\"open\", \"true\"); + } + + cursor = cursor.parentElement; + } + }, 150);")) +(text "{%- endif %}") + (div ("class" "flex flex-col gap-2") ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") @@ -2041,36 +2057,155 @@ (text "{%- endif %}") ; note listings - (text "{% for note in notes %} {% if not view_mode or note.title != \"journal.css\" -%}") - (div - ("class" "flex flex-row gap-1") - (a - ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") - ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") - (icon (text "file-text")) - (text "{{ note.title }}")) - - (text "{% if user and user.id == journal.owner -%}") - (div - ("class" "dropdown") - (button - ("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - ("style" "width: 32px") - (text "{{ icon \"ellipsis\" }}")) - (div - ("class" "inner") - (button - ("onclick" "change_note_title('{{ note.id }}')") - (icon (text "pencil")) - (str (text "chats:action.rename"))) - (button - ("onclick" "delete_note('{{ note.id }}')") - ("class" "red") - (icon (text "trash")) - (str (text "general:action.delete"))))) - (text "{%- endif %}")) - (text "{%- endif %} {% endfor %}")) + (text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")) (text "{%- endif %}") (text "{%- endmacro %}") + +(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}") +(details + (summary + ("class" "button w-full justify-start raised w-full") + (icon (text "folder")) + (text "{{ dir[2] }}")) + + (div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + (text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))) +(text "{%- endmacro %}") + +(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}") +; child dirs +(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}") +(text "{%- endif %} {% endfor %}") + +; child notes +(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}") + (text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}") +(text "{%- endif %} {%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro notes_list_note_listing(owner, journal, note) -%}") +(div + ("class" "flex flex-row gap-1") + ("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}") + (a + ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") + ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + (icon (text "file-text")) + (text "{{ note.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "change_note_title('{{ note.id }}')") + (icon (text "pencil")) + (str (text "chats:action.rename"))) + (a + ("class" "button") + ("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags") + (icon (text "tag")) + (str (text "journals:action.edit_tags"))) + (button + ("class" "button") + ("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()") + (icon (text "brush-cleaning")) + (str (text "journals:action.move"))) + (button + ("onclick" "delete_note('{{ note.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) +(text "{%- endmacro %}") + +(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}") +(div + ("class" "flex gap-1 flex-wrap") + (text "{% for tag in note.tags %}") + (a + ("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}") + ("class" "notification chip") + (span (text "{{ tag }}"))) + (text "{% endfor %}")) +(text "{%- endif %} {%- endmacro %}") + +(text "{% macro directories_editor(dirs) -%}") +(button + ("onclick" "create_directory('0')") + (icon (text "plus")) + (str (text "journals:action.create_root_dir"))) + +(text "{% for dir in dirs %} {% if dir[1] == 0 -%}") + (text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}") +(text "{%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro directories_editor_listing(dir, dirs) -%}") +(div + ("class" "flex flex-row gap-1") + (button + ("class" "justify-start lowered w-full") + (icon (text "folder-open")) + (text "{{ dir[2] }}")) + + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "create_directory('{{ dir[0] }}')") + (icon (text "plus")) + (str (text "journals:action.create_subdir"))) + (button + ("onclick" "delete_directory('{{ dir[0] }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete")))))) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; subdir listings + (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}") + (text "{%- endif %} {% endfor %}")) +(text "{%- endmacro %}") + +(text "{% macro note_mover_dirs(dirs) -%}") +(text "{% for dir in dirs %} {% if dir[1] == 0 -%}") + (text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}") +(text "{%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro note_mover_dirs_listing(dir, dirs) -%}") +(button + ("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()") + ("class" "justify-start lowered w-full") + (icon (text "folder-open")) + (text "{{ dir[2] }}")) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; subdir listings + (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") + (text "{%- endif %} {% endfor %}")) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 54a5415..697c466 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -83,23 +83,25 @@ (div ("class" "mobile_nav w-full flex items-center justify-between gap-2") (div - ("class" "flex gap-2 items-center") - (a - ("class" "flex items-center") - ("href" "/api/v1/auth/user/find/{{ journal.owner }}") - (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}")) + ("class" "flex flex-col gap-2") + (div + ("class" "flex gap-2 items-center") + (a + ("class" "flex items-center") + ("href" "/api/v1/auth/user/find/{{ journal.owner }}") + (text "{{ components::avatar(username=journal.owner, selector_type=\"id\", size=\"18px\") }}")) - (text "{% if (view_mode and owner) or not view_mode -%}") - (a - ("class" "flush") - ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") - (b (text "{{ journal.title }}"))) - (text "{%- endif %}") + (text "{% if (view_mode and owner) or not view_mode -%}") + (a + ("class" "flush") + ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") + (b (text "{{ journal.title }}"))) + (text "{%- endif %}") - (text "{% if note -%}") - (span (text "/")) - (b (text "{{ note.title }}")) - (text "{%- endif %}")) + (text "{% if note -%}") + (span (text "/")) + (b (text "{{ note.title }}")) + (text "{%- endif %}"))) (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}") (div @@ -196,10 +198,29 @@ (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))) + + ; users should also be able to manage the journal's sub directories here + (details + ("class" "w-full") + (summary + ("class" "button lowered w-full justify-start") + (icon (text "folders")) + (str (text "journals:label.directories"))) + + (div + ("class" "card flex flex-col gap-2 lowered") + (text "{{ components::directories_editor(dirs=journal.dirs) }}"))) (text "{% else %}") ; we're in view mode; just show journal listing and notes as journal homepage (div ("class" "card flex flex-col gap-2") + (text "{% if tag|length > 0 -%}") + (a + ("href" "?") + ("class" "notification chip w-content") + (text "{{ tag }}")) + (text "{%- endif %}") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}")) (text "{%- endif %}") (text "{% else %}") @@ -252,15 +273,30 @@ ("href" "#/preview") ("data-tab-button" "preview") ("data-turbo" "false") - (str (text "journals:label.preview_pane")))) + (str (text "journals:label.preview_pane"))) + + (a + ("href" "#/tags") + ("data-tab-button" "tags") + ("data-turbo" "false") + ("class" "hidden") + (str (text "journals:action.edit_tags")))) (text "{%- endif %}") ; tabs + (text "{{ components::note_tags(note=note) }}") + (div ("data-tab" "editor") - ("class" "flex flex-col gap-2 card") - ("style" "animation: fadein ease-in-out 1 0.5s forwards running") - ("id" "editor_tab")) + (div + ("class" "flex flex-col gap-2 card") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "editor_tab")) + + (button + ("onclick" "change_note_content('{{ note.id }}')") + (icon (text "check")) + (str (text "general:action.save")))) (div ("data-tab" "preview") @@ -268,10 +304,51 @@ ("style" "animation: fadein ease-in-out 1 0.5s forwards running") ("id" "preview_tab")) - (button - ("onclick" "change_note_content('{{ note.id }}')") - (icon (text "check")) - (str (text "general:action.save"))) + (div + ("data-tab" "tags") + ("class" "flex flex-col gap-2 card hidden") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + (form + ("onsubmit" "save_tags(event)") + ("class" "flex flex-col gap-1") + (label + ("for" "tags") + (str (text "journals:action.tags")) + (textarea + ("type" "text") + ("name" "tags") + ("id" "tags") + ("placeholder" "tags") + ("required" "") + ("minlength" "2") + ("maxlength" "128") + (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) + (span ("class" "fade") (text "Tags should be separated by a comma."))) + + (button + (icon (text "check")) + (str (text "general:action.save")))) + + (script + (text "globalThis.save_tags = (e) => { + event.preventDefault(); + fetch(\"/api/v1/notes/{{ selected_note }}/tags\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }"))) ; init codemirror (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) @@ -281,6 +358,7 @@ document.getElementById(\"preview_tab\").attachShadow({ mode: \"open\" }); } + document.getElementById(\"editor_tab\").innerHTML = \"\"; globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { value: document.getElementById(\"editor_content\").innerHTML, mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\", @@ -349,7 +427,11 @@ (div ("class" "flex w-full justify-between gap-2") - (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (div + ("class" "flex flex-col gap-2") + (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + (text "{{ components::note_tags(note=note) }}")) + (text "{% if user and user.id == owner.id -%}") (button ("class" "small") @@ -600,6 +682,99 @@ }); } + globalThis.create_directory = async (parent) => { + const name = await trigger(\"atto::prompt\", [\"Directory name:\"]); + + if (!name) { + return; + } + + fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name, + parent, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + globalThis.delete_directory = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will delete all notes within this directory.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", { + method: \"DELETE\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + id, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + }); + } + + globalThis.move_note_dir = async (id, dir) => { + fetch(`/api/v1/notes/${id}/dir`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + dir, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + // sidebars window.SIDEBARS_OPEN = false; if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { @@ -642,4 +817,24 @@ notes_list.style.left = \"0\"; } }"))) + +(text "{% if journal -%}") +; note mover +(dialog + ("id" "note_mover_dialog") + (div + ("class" "inner flex flex-col gap-2") + (p (text "Select a directory to move this note into:")) + (text "{{ components::note_mover_dirs(dirs=journal.dirs) }}") + (div + ("class" "flex justify-between") + (div) + (div + ("class" "flex gap-2") + (button + ("class" "bold red lowered") + ("onclick" "document.getElementById('note_mover_dialog').close()") + ("type" "button") + (text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}")))))) +(text "{%- endif %}") (text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 19944c6..45ac04f 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -4,9 +4,12 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; +use tetratto_shared::snow::Snowflake; use crate::{ get_user_from_token, - routes::api::v1::{UpdateJournalPrivacy, CreateJournal, UpdateJournalTitle}, + routes::api::v1::{ + AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle, + }, State, }; use tetratto_core::{ @@ -198,3 +201,86 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn add_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if props.name.len() > 32 { + return Json(Error::DataTooLong("name".to_string()).into()); + } + + let mut journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // add dir + journal.dirs.push(( + Snowflake::new().to_string().parse::().unwrap(), + match props.parent.parse() { + Ok(p) => p, + Err(_) => return Json(Error::Unknown.into()), + }, + props.name, + )); + + // ... + match data.update_journal_dirs(id, &user, journal.dirs).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn remove_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // add dir + let dir_id: usize = match props.dir.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }; + + journal + .dirs + .remove(match journal.dirs.iter().position(|x| x.0 == dir_id) { + Some(idx) => idx, + None => return Json(Error::GeneralNotFound("directory".to_string()).into()), + }); + + // ... + match data.update_journal_dirs(id, &user, journal.dirs).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d529c60..44467ba 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -570,14 +570,22 @@ pub fn routes() -> Router { "/journals/{id}/privacy", post(journals::update_privacy_request), ) + .route("/journals/{id}/dirs", post(journals::add_dir_request)) + .route("/journals/{id}/dirs", delete(journals::remove_dir_request)) // notes .route("/notes", post(notes::create_request)) .route("/notes/{id}", get(notes::get_request)) .route("/notes/{id}", delete(notes::delete_request)) .route("/notes/{id}/title", post(notes::update_title_request)) .route("/notes/{id}/content", post(notes::update_content_request)) + .route("/notes/{id}/dir", post(notes::update_dir_request)) + .route("/notes/{id}/tags", post(notes::update_tags_request)) .route("/notes/from_journal/{id}", get(notes::list_request)) .route("/notes/preview", post(notes::render_markdown_request)) + .route( + "/notes/{journal}/dir/{dir}", + delete(notes::delete_by_dir_request), + ) // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) @@ -926,3 +934,24 @@ pub struct CreateMessageReaction { pub message: String, pub emoji: String, } + +#[derive(Deserialize)] +pub struct UpdateNoteDir { + pub dir: String, +} + +#[derive(Deserialize)] +pub struct AddJournalDir { + pub name: String, + #[serde(default)] + pub parent: String, +} + +#[derive(Deserialize)] +pub struct RemoveJournalDir { + pub dir: String, +} +#[derive(Deserialize)] +pub struct UpdateNoteTags { + pub tags: Vec, +} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index faf1bec..9a18559 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -7,7 +7,10 @@ use axum_extra::extract::CookieJar; use tetratto_shared::unix_epoch_timestamp; use crate::{ get_user_from_token, - routes::api::v1::{CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteTitle}, + routes::api::v1::{ + CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags, + UpdateNoteTitle, + }, State, }; use tetratto_core::{ @@ -222,8 +225,96 @@ pub async fn delete_request( } } +pub async fn delete_by_dir_request( + jar: CookieJar, + Path((journal, id)): Path<(usize, usize)>, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_notes_by_journal_dir(journal, id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Notes deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn render_markdown_request(Json(req): Json) -> impl IntoResponse { tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content)) .replace("\\@", "@") .replace("%5C@", "@") } + +pub async fn update_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let journal = match data.get_journal_by_id(note.journal).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // make sure dir exists + let dir = match props.dir.parse::() { + Ok(d) => d, + Err(_) => return Json(Error::Unknown.into()), + }; + + if dir != 0 { + if journal.dirs.iter().find(|x| x.0 == dir).is_none() { + return Json(Error::GeneralNotFound("directory".to_string()).into()); + } + } + + // ... + match data.update_note_dir(id, &user, dir as i64).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_tags_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_note_tags(id, &user, props.tags).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index 1f03dd7..434c819 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -158,14 +158,6 @@ pub async fn view_request( } } - // ... - let notes = match data.0.get_notes_by_journal(journal.id).await { - Ok(p) => Some(p), - Err(e) => { - return Err(Html(render_error(e, &jar, &data, &user).await)); - } - }; - // ... let note = if !selected_note.is_empty() { match data @@ -199,7 +191,7 @@ pub async fn view_request( context.insert("note", ¬e); context.insert("owner", &owner); - context.insert("notes", ¬es); + context.insert::<[i8; 0], &str>("notes", &[]); context.insert("view_mode", &true); context.insert("is_editor", &false); @@ -213,6 +205,7 @@ pub async fn index_view_request( jar: CookieJar, Extension(data): Extension, Path((owner, selected_journal)): Path<(String, String)>, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -257,10 +250,23 @@ pub async fn index_view_request( } // ... - let notes = match data.0.get_notes_by_journal(journal.id).await { - Ok(p) => Some(p), - Err(e) => { - return Err(Html(render_error(e, &jar, &data, &user).await)); + let notes = if props.tag.is_empty() { + match data.0.get_notes_by_journal(journal.id).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + } + } else { + match data + .0 + .get_notes_by_journal_tag(journal.id, &props.tag) + .await + { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } } }; @@ -281,6 +287,7 @@ pub async fn index_view_request( context.insert("view_mode", &true); context.insert("is_editor", &false); + context.insert("tag", &props.tag); // return Ok(Html(data.1.render("journals/app.html", &context).unwrap())) diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 2eaeca2..9115f65 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -196,4 +196,6 @@ pub struct RepostsQuery { pub struct JournalsAppQuery { #[serde(default)] pub view: bool, + #[serde(default)] + pub tag: String, } diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql index 40eafa4..47f4a6e 100644 --- a/crates/core/src/database/drivers/sql/create_journals.sql +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS journals ( created BIGINT NOT NULL, owner BIGINT NOT NULL, title TEXT NOT NULL, - privacy TEXT NOT NULL + privacy TEXT NOT NULL, + dirs TEXT NOT NUll ) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql index 87361ad..2c85588 100644 --- a/crates/core/src/database/drivers/sql/create_notes.sql +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -5,5 +5,7 @@ CREATE TABLE IF NOT EXISTS notes ( title TEXT NOT NULL, journal BIGINT NOT NULL, content TEXT NOT NULL, - edited BIGINT NOT NULL + edited BIGINT NOT NULL, + dir BIGINT NOT NULL, + tags TEXT NOT NULL ) diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index a4a0d00..2ad4078 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -20,6 +20,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, title: get!(x->3(String)), privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), + dirs: serde_json::from_str(&get!(x->5(String))).unwrap(), } } @@ -128,13 +129,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO journals VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO journals VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &data.title, &serde_json::to_string(&data.privacy).unwrap(), + &serde_json::to_string(&data.dirs).unwrap(), ] ); @@ -183,4 +185,5 @@ impl DataManager { auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index ea7da45..364a540 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -15,6 +15,8 @@ impl DataManager { journal: get!(x->4(i64)) as usize, content: get!(x->5(String)), edited: get!(x->6(i64)) as usize, + dir: get!(x->7(i64)) as usize, + tags: serde_json::from_str(&get!(x->8(String))).unwrap(), } } @@ -65,6 +67,31 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all notes by journal with the given tag. + /// + /// # Arguments + /// * `id` - the ID of the journal to fetch notes for + /// * `tag` + pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC", + params![&(id as i64), tag], + |x| { Self::get_note_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; /// Create a new note in the database. @@ -137,7 +164,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO notes VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", params![ &(data.id as i64), &(data.created as i64), @@ -146,6 +173,8 @@ impl DataManager { &(data.journal as i64), &data.content, &(data.edited as i64), + &(data.dir as i64), + &serde_json::to_string(&data.tags).unwrap(), ] ); @@ -181,7 +210,45 @@ impl DataManager { Ok(()) } + /// Delete all notes by dir ID. + /// + /// # Arguments + /// * `journal` + /// * `dir` + pub async fn delete_notes_by_journal_dir( + &self, + journal: usize, + dir: usize, + user: &User, + ) -> Result<()> { + let journal = self.get_journal_by_id(journal).await?; + + if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC", + &[&(dir as i64), &(journal.id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); + auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); + auto_method!(update_note_tags(Vec)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.note:{}"); auto_method!(update_note_edited(i64) -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl="atto.note:{}"); } diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs index f67b318..3d70ce0 100644 --- a/crates/core/src/model/journals.rs +++ b/crates/core/src/model/journals.rs @@ -22,6 +22,10 @@ pub struct Journal { pub owner: usize, pub title: String, pub privacy: JournalPrivacyPermission, + /// An array of directories notes can be placed in. + /// + /// `Vec<(id, parent id, name)>` + pub dirs: Vec<(usize, usize, String)>, } impl Journal { @@ -33,6 +37,7 @@ impl Journal { owner, title, privacy: JournalPrivacyPermission::default(), + dirs: Vec::new(), } } } @@ -49,6 +54,12 @@ pub struct Note { pub journal: usize, pub content: String, pub edited: usize, + /// The "id" of the directoryy this note is in. + /// + /// Directories are held in the journal in the `dirs` column. + pub dir: usize, + /// An array of tags associated with the note. + pub tags: Vec, } impl Note { @@ -64,6 +75,8 @@ impl Note { journal, content, edited: created, + dir: 0, + tags: Vec::new(), } } } diff --git a/sql_changes/journals_dirs.sql b/sql_changes/journals_dirs.sql new file mode 100644 index 0000000..72d1aaf --- /dev/null +++ b/sql_changes/journals_dirs.sql @@ -0,0 +1,2 @@ +ALTER TABLE journals +ADD COLUMN dirs TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/notes_dir_tags.sql b/sql_changes/notes_dir_tags.sql new file mode 100644 index 0000000..0bf24d1 --- /dev/null +++ b/sql_changes/notes_dir_tags.sql @@ -0,0 +1,5 @@ +ALTER TABLE notes +ADD COLUMN dir BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE notes +ADD COLUMN tags TEXT NOT NULL DEFAULT '[]';