This commit is contained in:
Michael Zhang 2024-05-27 14:44:24 -05:00
parent 4f31d10da4
commit 52ae6ce480
23 changed files with 530 additions and 155 deletions

11
README.md Normal file
View file

@ -0,0 +1,11 @@
panorama
========
Personal information manager.
Contact
-------
Author: Michael Zhang
License: GPL-3.0-only

View file

@ -22,6 +22,7 @@
"@uiw/react-md-editor": "^4.0.4", "@uiw/react-md-editor": "^4.0.4",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"date-fns": "^3.6.0", "date-fns": "^3.6.0",
"formik": "^2.4.6",
"hast-util-to-jsx-runtime": "^2.3.0", "hast-util-to-jsx-runtime": "^2.3.0",
"hast-util-to-mdast": "^10.1.0", "hast-util-to-mdast": "^10.1.0",
"immutable": "^4.3.6", "immutable": "^4.3.6",

View file

@ -4,12 +4,12 @@
// Learn more about Tauri commands at https://tauri.app/v1/guides/features/command // Learn more about Tauri commands at https://tauri.app/v1/guides/features/command
#[tauri::command] #[tauri::command]
fn greet(name: &str) -> String { fn greet(name: &str) -> String {
format!("Hello, {}! You've been greeted from Rust!", name) format!("Hello, {}! You've been greeted from Rust!", name)
} }
fn main() { fn main() {
tauri::Builder::default() tauri::Builder::default()
.invoke_handler(tauri::generate_handler![greet]) .invoke_handler(tauri::generate_handler![greet])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View file

@ -40,9 +40,9 @@ function App() {
})(); })();
}, [nodesOpened, openNode]); }, [nodesOpened, openNode]);
const nodes = nodesOpened const nodes = [...nodesOpened.reverse().values()].map((nodeId, idx) => (
.reverse() <NodeDisplay idx={idx} key={nodeId} id={nodeId} />
.map((nodeId) => <NodeDisplay key={nodeId} id={nodeId} />); ));
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>

View file

@ -5,13 +5,13 @@ import { getVersion } from "@tauri-apps/api/app";
import ListIcon from "@mui/icons-material/List"; import ListIcon from "@mui/icons-material/List";
import { useSetAtom } from "jotai"; import { useSetAtom } from "jotai";
import { sidebarExpandedAtom } from "./Sidebar"; import { sidebarExpandedAtom } from "./Sidebar";
import { nodesOpenedAtom } from "../App"; import { nodesOpenedAtom, useOpenNode } from "../App";
import { useCallback } from "react"; import { useCallback } from "react";
const version = await getVersion(); const version = await getVersion();
export default function Header() { export default function Header() {
const setNodesOpened = useSetAtom(nodesOpenedAtom); const openNode = useOpenNode();
const setSidebarExpanded = useSetAtom(sidebarExpandedAtom); const setSidebarExpanded = useSetAtom(sidebarExpandedAtom);
const createNewJournalPage = useCallback(() => { const createNewJournalPage = useCallback(() => {
@ -29,9 +29,9 @@ export default function Header() {
}), }),
}); });
const data = await resp.json(); const data = await resp.json();
setNodesOpened((prev) => [data.node_id, ...prev]); openNode(data.node_id);
})(); })();
}, [setNodesOpened]); }, [openNode]);
return ( return (
<> <>

View file

@ -14,17 +14,44 @@
} }
.header { .header {
padding: 2px 12px;
color: rgb(106, 103, 160); color: rgb(106, 103, 160);
background: rgb(204, 201, 255); background: rgb(204, 201, 255);
background: linear-gradient(90deg, rgba(204, 201, 255, 1) 0%, rgba(255, 255, 255, 1) 100%); background: linear-gradient(90deg, rgba(204, 201, 255, 1) 0%, rgba(255, 255, 255, 1) 100%);
font-size: 0.6rem; font-size: 0.6rem;
display: flex;
align-items: center;
gap: 6px;
padding: 0 6px;
button {
background-color: transparent;
border: none;
display: flex;
justify-content: center;
cursor: pointer;
align-items: center;
&:hover {
background-color: rgba(0, 0, 0, 0.25);
}
}
span {
padding: 2px 0;
}
} }
.footer { .footer {
padding: 2px 12px; padding: 2px 12px;
font-size: 0.6rem; font-size: 0.6rem;
align-self: flex-start;
// For the resize handle
// TODO: Make the entire right side resize and then remove this
width: calc(100% - 20px);
// border: 1px solid red;
// box-sizing: border-box;
} }
.body { .body {
@ -34,16 +61,4 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
}
.title {
padding: 12px;
border: none;
border-bottom: 1px solid lightgray;
outline: none;
font-size: 1.2em;
}
.untitled {
color: gray;
} }

View file

@ -2,88 +2,44 @@ import { useQuery } from "@tanstack/react-query";
import styles from "./NodeDisplay.module.scss"; import styles from "./NodeDisplay.module.scss";
import ReactTimeAgo from "react-time-ago"; import ReactTimeAgo from "react-time-ago";
import JournalPage from "./nodes/JournalPage"; import JournalPage from "./nodes/JournalPage";
import { useCallback, useEffect, useState } from "react"; import { getNode } from "../lib/getNode";
import FirstPageIcon from "@mui/icons-material/FirstPage";
import { useOpenNode } from "../App";
export interface NodeDisplayProps { export interface NodeDisplayProps {
id: string; id: string;
idx?: number | undefined;
} }
export default function NodeDisplay({ id }: NodeDisplayProps) { export default function NodeDisplay({ id, idx }: NodeDisplayProps) {
const query = useQuery({ const query = useQuery({
queryKey: ["fetchNode", id], queryKey: ["fetchNode", id],
queryFn: async () => { queryFn: getNode,
const resp = await fetch(`http://localhost:5195/node/${id}`);
const json = await resp.json();
return json;
},
}); });
const { isSuccess, status, data } = query; const { isSuccess, status, data: nodeDescriptor } = query;
const [isEditingTitle, setIsEditingTitle] = useState(false); let Component = undefined;
const [title, setTitle] = useState(() => let data = undefined;
isSuccess && data ? data.title : undefined, if (isSuccess) {
); Component = nodeDescriptor.render;
data = nodeDescriptor.data;
useEffect(() => { }
if (data) {
setTitle(data.title);
}
}, [data]);
const saveChangedTitle = useCallback(() => {
(async () => {
const resp = await fetch(`http://localhost:5195/node/${id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title }),
});
setIsEditingTitle(false);
})();
}, [title, id]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
<div className={styles.header}> <div className={styles.header}>
{isSuccess ? ( {isSuccess ? (
<NodeDisplayHeaderLoaded id={id} data={data} /> <NodeDisplayHeaderLoaded idx={idx} id={id} data={data} />
) : ( ) : (
<>ID {id}</> <>
ID {id} ({status})
</>
)} )}
</div> </div>
{isEditingTitle ? (
<form
onSubmit={(evt) => {
evt.preventDefault();
saveChangedTitle();
}}
>
<input
className={styles.title}
type="text"
value={title}
onChange={(evt) => setTitle(evt.target.value)}
onBlur={() => saveChangedTitle()}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus
/>
</form>
) : (
<div
className={styles.title}
onDoubleClick={() => setIsEditingTitle(true)}
>
{title ?? <span className={styles.untitled}>(untitled)</span>}
</div>
)}
<div className={styles.body}> <div className={styles.body}>
{isSuccess ? ( {Component && <Component id={id} data={data} />}
<NodeDisplayLoaded id={id} data={data} />
) : (
<>Status: {status}</>
)}
</div> </div>
<div className={styles.footer}>{id}</div> <div className={styles.footer}>{id}</div>
@ -91,25 +47,27 @@ export default function NodeDisplay({ id }: NodeDisplayProps) {
); );
} }
function NodeDisplayHeaderLoaded({ id, data }) { function NodeDisplayHeaderLoaded({ idx, id, data }) {
const openNode = useOpenNode();
return ( return (
<> <>
Type {data.type} &middot; Last updated{" "} {idx === 0 || (
<ReactTimeAgo date={data.created_at * 1000} /> <button
type="button"
onClick={() => openNode(id)}
title="Move node to the left"
>
<FirstPageIcon fontSize="inherit" />
</button>
)}
<span>
Type {data.type}{" "}
{data.created_at && (
<>
&middot; Last updated <ReactTimeAgo date={data.updated_at * 1000} />
</>
)}
</span>
</> </>
); );
} }
function NodeDisplayLoaded({ id, data }) {
switch (data.type) {
case "panorama/journal/page":
return <JournalPage id={id} data={data} />;
default:
return (
<>
Don't know how to render node of type <code>{data.type}</code>
</>
);
}
}

View file

@ -93,21 +93,23 @@ function SearchMenu({ results }) {
return ( return (
<div className={styles.searchResults}> <div className={styles.searchResults}>
{results.map((result) => ( {results.map((result) => {
<button return (
type="button" <button
key={result.node_id} type="button"
className={styles.searchResult} key={result.node_id}
onClick={() => { className={styles.searchResult}
setSearchQuery(""); onClick={() => {
setShowMenu(false); setSearchQuery("");
openNode(result.node_id); setShowMenu(false);
}} openNode(result.node_id);
> }}
<div className={styles.title}>{result.title}</div> >
<div className={styles.subtitle}>{result.content}</div> <div className={styles.title}>{result.title}</div>
</button> <div className={styles.subtitle}>{result.content}</div>
))} </button>
);
})}
</div> </div>
); );
} }

View file

@ -12,6 +12,8 @@
padding: 6px; padding: 6px;
font-size: 0.95em; font-size: 0.95em;
border-radius: 4px; border-radius: 4px;
border: none;
background-color: unset;
&:hover { &:hover {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);

View file

@ -3,11 +3,13 @@ import styles from "./Sidebar.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import EmailIcon from "@mui/icons-material/Email"; import EmailIcon from "@mui/icons-material/Email";
import SettingsIcon from "@mui/icons-material/Settings"; import SettingsIcon from "@mui/icons-material/Settings";
import { useOpenNode } from "../App";
export const sidebarExpandedAtom = atom(false); export const sidebarExpandedAtom = atom(false);
export default function Sidebar() { export default function Sidebar() {
const sidebarExpanded = useAtomValue(sidebarExpandedAtom); const sidebarExpanded = useAtomValue(sidebarExpandedAtom);
const openNode = useOpenNode();
return ( return (
<div <div
@ -16,10 +18,14 @@ export default function Sidebar() {
sidebarExpanded ? styles.expanded : styles.collapsed, sidebarExpanded ? styles.expanded : styles.collapsed,
)} )}
> >
<div className={styles.item}> <button
type="button"
className={styles.item}
onClick={() => openNode("panorama/mail")}
>
<EmailIcon /> <EmailIcon />
<span className={styles.label}>Email</span> <span className={styles.label}>Email</span>
</div> </button>
<div className="spacer" /> <div className="spacer" />

View file

@ -4,6 +4,18 @@
flex-direction: column; flex-direction: column;
} }
.title {
padding: 12px;
border: none;
border-bottom: 1px solid lightgray;
outline: none;
font-size: 1.2em;
}
.untitled {
color: gray;
}
.mdContent { .mdContent {
flex-grow: 1; flex-grow: 1;
display: flex; display: flex;

View file

@ -1,16 +1,18 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import MDEditor, { PreviewType } from "@uiw/react-md-editor"; import MDEditor, { PreviewType } from "@uiw/react-md-editor";
import { usePrevious, useDebounce } from "@uidotdev/usehooks"; import { usePrevious } from "@uidotdev/usehooks";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import styles from "./JournalPage.module.scss"; import styles from "./JournalPage.module.scss";
import remarkMath from "remark-math"; import remarkMath from "remark-math";
import rehypeKatex from "rehype-katex"; import rehypeKatex from "rehype-katex";
import remarkEmbedder from "@remark-embedder/core";
import { parse as parseDate, format as formatDate } from "date-fns"; import { parse as parseDate, format as formatDate } from "date-fns";
import { useDebounce } from "use-debounce";
export interface JournalPageProps { export interface JournalPageProps {
id: string; id: string;
data: { data: {
day?: string;
title?: string;
content: string; content: string;
}; };
} }
@ -19,13 +21,19 @@ export default function JournalPage({ id, data }: JournalPageProps) {
const { day } = data; const { day } = data;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [value, setValue] = useState(() => data.content); const [value, setValue] = useState(() => data.content);
const valueToSave = useDebounce(value, 1000); const [valueToSave] = useDebounce(value, 1000, {
leading: true,
trailing: true,
});
const previous = usePrevious(valueToSave); const previous = usePrevious(valueToSave);
const changed = valueToSave !== previous; const changed = valueToSave !== previous;
const [mode, setMode] = useState<PreviewType>("preview"); const [mode, setMode] = useState<PreviewType>("preview");
const [title, setTitle] = useState(() => data.title);
const [isEditingTitle, setIsEditingTitle] = useState(false);
useEffect(() => { useEffect(() => {
if (changed) { if (changed) {
// console.log("OLD", previous, "NEW", valueToSave);
(async () => { (async () => {
console.log("Saving..."); console.log("Saving...");
const resp = await fetch(`http://localhost:5195/node/${id}`, { const resp = await fetch(`http://localhost:5195/node/${id}`, {
@ -47,23 +55,63 @@ export default function JournalPage({ id, data }: JournalPageProps) {
} }
}, [id, changed, valueToSave, queryClient]); }, [id, changed, valueToSave, queryClient]);
return ( const saveChangedTitle = useCallback(() => {
<div data-color-mode="light" className={styles.container}> (async () => {
{day && <DayIndicator day={day} />} const resp = await fetch(`http://localhost:5195/node/${id}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ title: title }),
});
setIsEditingTitle(false);
})();
}, [title, id]);
<MDEditor return (
value={value} <>
className={styles.mdEditor} {isEditingTitle ? (
onChange={(newValue) => newValue !== undefined && setValue(newValue)} <form
preview={mode} onSubmit={(evt) => {
visibleDragbar={false} evt.preventDefault();
onDoubleClick={() => setMode("live")} saveChangedTitle();
previewOptions={{ }}
remarkPlugins: [remarkMath], >
rehypePlugins: [rehypeKatex], <input
}} className={styles.title}
/> type="text"
</div> value={title}
onChange={(evt) => setTitle(evt.target.value)}
onBlur={() => saveChangedTitle()}
// biome-ignore lint/a11y/noAutofocus: <explanation>
autoFocus
/>
</form>
) : (
<div
className={styles.title}
onDoubleClick={() => setIsEditingTitle(true)}
>
{title ?? <span className={styles.untitled}>(untitled)</span>}
</div>
)}
<div data-color-mode="light" className={styles.container}>
{day && <DayIndicator day={day} />}
<MDEditor
value={value}
className={styles.mdEditor}
onChange={(newValue) => newValue !== undefined && setValue(newValue)}
preview={mode}
visibleDragbar={false}
onDoubleClick={() => setMode("live")}
previewOptions={{
remarkPlugins: [remarkMath],
rehypePlugins: [rehypeKatex],
}}
/>
</div>
</>
); );
} }

View file

@ -0,0 +1,25 @@
.container {
display: flex;
flex-direction: column;
}
.header {
display: flex;
align-items: center;
padding: 2px 12px;
.title {
font-size: 1rem;
}
}
.settings {
h2 {
margin: 0;
}
}
.mailList {
flex-grow: 1;
border-top: 1px solid lightgray;
}

View file

@ -0,0 +1,134 @@
import { useCallback, useState } from "react";
import styles from "./Mail.module.scss";
import { Formik } from "formik";
import SettingsIcon from "@mui/icons-material/Settings";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export default function Mail() {
const [showSettings, setShowSettings] = useState(false);
return (
<div className={styles.container}>
<div className={styles.header}>
<div className={styles.title}>
{showSettings ? <>Settings</> : <>Mail</>}
</div>
<div className="spacer" />
<button type="button" onClick={() => setShowSettings((prev) => !prev)}>
<SettingsIcon />
</button>
</div>
{showSettings && (
<div className={styles.settings}>
<MailConfig />
</div>
)}
<div className={styles.mailList}></div>
</div>
);
}
function MailConfig() {
const queryClient = useQueryClient();
const config = useQuery({
queryKey: ["mailConfigs"],
queryFn: fetchMailConfig,
});
const { isSuccess, data } = config;
return (
<>
<div>
Add a new config
<Formik
onSubmit={(values) => {
(async () => {
const resp = await fetch("http://localhost:5195/node", {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
type: "panorama/mail/config",
extra_data: {
"panorama/mail/config/imap_hostname": values.imapHostname,
"panorama/mail/config/imap_port": values.imapPort,
"panorama/mail/config/imap_username": values.imapUsername,
"panorama/mail/config/imap_password": values.imapPassword,
},
}),
});
const data = await resp.json();
console.log("result", data);
queryClient.invalidateQueries({ queryKey: ["mailConfigs"] });
})();
}}
initialValues={{
imapHostname: "",
imapPort: 993,
imapUsername: "",
imapPassword: "",
}}
>
{({ values, handleSubmit, handleChange, handleBlur }) => (
<form onSubmit={handleSubmit}>
<input
type="text"
name="imapHostname"
placeholder="IMAP Hostname"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapHostname}
/>
<input
type="number"
name="imapPort"
placeholder="IMAP Port"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapPort}
/>
<br />
<input
type="text"
name="imapUsername"
placeholder="IMAP Username"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapUsername}
/>
<input
type="password"
name="imapPassword"
placeholder="IMAP Password"
onChange={handleChange}
onBlur={handleBlur}
value={values.imapPassword}
/>
<button type="submit">Add</button>
</form>
)}
</Formik>
</div>
<div>
{isSuccess && (
<ul>
{data.map((config) => (
<li>{JSON.stringify(config)}</li>
))}
</ul>
)}
</div>
</>
);
}
async function fetchMailConfig() {
const resp = await fetch("http://localhost:5195/mail/config");
const data = await resp.json();
return data.configs;
}

View file

@ -16,4 +16,8 @@ html,
.spacer { .spacer {
flex-grow: 1; flex-grow: 1;
}
* {
box-sizing: border-box;
} }

34
app/src/lib/getNode.ts Normal file
View file

@ -0,0 +1,34 @@
import { Component, FC } from "react";
import JournalPage from "../components/nodes/JournalPage";
import Mail from "../components/nodes/Mail";
import { QueryFunctionContext } from "@tanstack/react-query";
export interface RenderProps {
id: string;
data: any;
}
export interface NodeDescriptor {
render: FC<RenderProps>;
data: {
type: string;
} & any;
}
export async function getNode({
queryKey,
}: QueryFunctionContext): Promise<NodeDescriptor> {
const [, node_id] = queryKey;
switch (node_id) {
case "panorama/mail":
return { data: { type: "panorama/mail" }, render: Mail };
default: {
const resp = await fetch(`http://localhost:5195/node/${node_id}`);
const data = await resp.json();
return {
data: { ...data, type: "panorama/journal/page" },
render: JournalPage,
};
}
}
}

View file

@ -47,8 +47,6 @@ pub async fn export(State(state): State<AppState>) -> AppResult<()> {
relation_columns.insert(relation_name.clone(), columns); relation_columns.insert(relation_name.clone(), columns);
} }
println!("columns: {relation_columns:?}");
let base_dir = PathBuf::from("export"); let base_dir = PathBuf::from("export");
fs::create_dir_all(&base_dir); fs::create_dir_all(&base_dir);
@ -68,7 +66,6 @@ pub async fn export(State(state): State<AppState>) -> AppResult<()> {
.join(", "); .join(", ");
let query = format!("?[{columns}] := *{relation_name} {{ {columns} }}"); let query = format!("?[{columns}] := *{relation_name} {{ {columns} }}");
println!("Query: {query}");
let result = tx.run_script(&query, Default::default())?; let result = tx.run_script(&query, Default::default())?;
writer.write_record(result.headers).unwrap(); writer.write_record(result.headers).unwrap();

View file

@ -10,7 +10,6 @@ pub async fn get_todays_journal_id(
State(state): State<AppState>, State(state): State<AppState>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let today = todays_date(); let today = todays_date();
println!("Getting journal id for {today}!");
let result = state.db.run_script( let result = state.db.run_script(
" "
@ -22,8 +21,6 @@ pub async fn get_todays_journal_id(
ScriptMutability::Immutable, ScriptMutability::Immutable,
)?; )?;
println!("Result: {:?}", result);
// TODO: Do this check on the server side // TODO: Do this check on the server side
if result.rows.len() == 0 { if result.rows.len() == 0 {
// Insert a new one // Insert a new one

View file

@ -0,0 +1,53 @@
use axum::{extract::State, Json};
use cozo::{DbInstance, ScriptMutability};
use serde_json::Value;
use crate::{error::AppResult, AppState};
pub async fn get_mail_config(
State(state): State<AppState>,
) -> AppResult<Json<Value>> {
let configs = fetch_mail_configs(&state.db)?;
Ok(Json(json!({
"configs": configs,
})))
}
pub async fn mail_loop(db: DbInstance) {
// Fetch the mail configs
}
#[derive(Serialize)]
struct MailConfig {
node_id: String,
imap_hostname: String,
imap_port: u16,
imap_username: String,
imap_password: String,
}
fn fetch_mail_configs(db: &DbInstance) -> AppResult<Vec<MailConfig>> {
let result = db.run_script(
"
?[node_id, imap_hostname, imap_port, imap_username, imap_password] :=
*node{ id: node_id },
*mail_config{ node_id, imap_hostname, imap_port, imap_username, imap_password }
",
Default::default(),
ScriptMutability::Immutable,
)?;
let result = result
.rows
.into_iter()
.map(|row| MailConfig {
node_id: row[0].get_str().unwrap().to_owned(),
imap_hostname: row[1].get_str().unwrap().to_owned(),
imap_port: row[2].get_int().unwrap() as u16,
imap_username: row[3].get_str().unwrap().to_owned(),
imap_password: row[4].get_str().unwrap().to_owned(),
})
.collect::<Vec<_>>();
Ok(result)
}

View file

@ -10,6 +10,7 @@ extern crate sugars;
mod error; mod error;
mod export; mod export;
mod journal; mod journal;
mod mail;
mod migrations; mod migrations;
mod node; mod node;
mod query_builder; mod query_builder;
@ -31,6 +32,7 @@ use tower_http::cors::{self, CorsLayer};
use crate::{ use crate::{
export::export, export::export,
journal::get_todays_journal_id, journal::get_todays_journal_id,
mail::{get_mail_config, mail_loop},
migrations::run_migrations, migrations::run_migrations,
node::{create_node, get_node, node_types, search_nodes, update_node}, node::{create_node, get_node, node_types, search_nodes, update_node},
}; };
@ -56,6 +58,8 @@ async fn main() -> Result<()> {
run_migrations(&db).await?; run_migrations(&db).await?;
tokio::spawn(mail_loop(db.clone()));
let state = AppState { db }; let state = AppState { db };
let cors = CorsLayer::new() let cors = CorsLayer::new()
@ -73,6 +77,7 @@ async fn main() -> Result<()> {
.route("/node/:id", post(update_node)) .route("/node/:id", post(update_node))
.route("/node/types", get(node_types)) .route("/node/types", get(node_types))
.route("/journal/get_todays_journal_id", get(get_todays_journal_id)) .route("/journal/get_todays_journal_id", get(get_todays_journal_id))
.route("/mail/config", get(get_mail_config))
.layer(ServiceBuilder::new().layer(cors)) .layer(ServiceBuilder::new().layer(cors))
.with_state(state); .with_state(state);
@ -88,7 +93,8 @@ pub fn ensure_ok(s: &str) -> Result<()> {
let status = status.as_object().unwrap(); let status = status.as_object().unwrap();
let ok = status.get("ok").unwrap().as_bool().unwrap_or(false); let ok = status.get("ok").unwrap().as_bool().unwrap_or(false);
if !ok { if !ok {
bail!("shit (error: {s})") let display = status.get("display").unwrap().as_str().unwrap();
bail!("shit (error: {display})")
} }
Ok(()) Ok(())
} }

View file

@ -51,6 +51,7 @@ pub async fn run_migrations(db: &DbInstance) -> Result<()> {
&format!("{{\"version\":{}}}", idx), &format!("{{\"version\":{}}}", idx),
false, false,
); );
ensure_ok(&result)?; ensure_ok(&result)?;
println!("succeeded migration {idx}!"); println!("succeeded migration {idx}!");
@ -128,7 +129,11 @@ fn migration_01(db: &DbInstance) -> Result<()> {
} }
{ {
?[key, relation, field_name, type] <- [ ?[key, relation, field_name, type] <- [
['panorama/journal/page/content', 'journal', 'content', 'string'] ['panorama/journal/page/content', 'journal', 'content', 'string'],
['panorama/mail/config/imap_hostname', 'mail_config', 'imap_hostname', 'string'],
['panorama/mail/config/imap_port', 'mail_config', 'imap_port', 'int'],
['panorama/mail/config/imap_username', 'mail_config', 'imap_username', 'string'],
['panorama/mail/config/imap_password', 'mail_config', 'imap_password', 'string'],
] ]
:put fqkey_to_dbkey { key, relation, field_name, type } :put fqkey_to_dbkey { key, relation, field_name, type }
} }
@ -144,6 +149,20 @@ fn migration_01(db: &DbInstance) -> Result<()> {
filters: [Lowercase, Stemmer('english'), Stopwords('en')], filters: [Lowercase, Stemmer('english'), Stopwords('en')],
} }
} }
# Mail
{
:create mail_config {
node_id: String
=>
imap_hostname: String,
imap_port: Int,
imap_username: String,
imap_password: String,
}
}
# Calendar
", ",
"", "",
false, false,

View file

@ -70,7 +70,6 @@ pub async fn update_node(
Path(node_id): Path<String>, Path(node_id): Path<String>,
Json(update_data): Json<UpdateData>, Json(update_data): Json<UpdateData>,
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
println!("Update data: {:?}", update_data);
let node_id_data = DataValue::from(node_id.clone()); let node_id_data = DataValue::from(node_id.clone());
// TODO: Combine these into the same script // TODO: Combine these into the same script
@ -158,7 +157,6 @@ pub async fn create_node(
) -> AppResult<Json<Value>> { ) -> AppResult<Json<Value>> {
let node_id = Uuid::now_v7(); let node_id = Uuid::now_v7();
let node_id = node_id.to_string(); let node_id = node_id.to_string();
println!("Opts: {opts:?}");
let tx = state.db.multi_transaction(true); let tx = state.db.multi_transaction(true);
@ -179,15 +177,17 @@ pub async fn create_node(
let result_by_relation = result let result_by_relation = result
.iter() .iter()
.into_group_map_by(|(key, (relation, field_name, ty))| relation); .into_group_map_by(|(key, (relation, field_name, ty))| relation);
println!("Result by relation: {result_by_relation:?}");
for (relation, fields) in result_by_relation.iter() { for (relation, fields) in result_by_relation.iter() {
let fields_mapping = fields let fields_mapping = fields
.into_iter() .into_iter()
.map(|(key, (_, field_name, _))| { .map(|(key, (_, field_name, ty))| {
let new_value = extra_data.get(*key).unwrap(); let new_value = extra_data.get(*key).unwrap();
// TODO: Make this more generic // TODO: Make this more generic
let new_value = DataValue::from(new_value.as_str().unwrap()); let new_value = match ty.as_str() {
"int" => DataValue::from(new_value.as_i64().unwrap()),
_ => DataValue::from(new_value.as_str().unwrap()),
};
(field_name.to_owned(), new_value) (field_name.to_owned(), new_value)
}) })
.collect::<BTreeMap<_, _>>(); .collect::<BTreeMap<_, _>>();

View file

@ -47,6 +47,9 @@ importers:
date-fns: date-fns:
specifier: ^3.6.0 specifier: ^3.6.0
version: 3.6.0 version: 3.6.0
formik:
specifier: ^2.4.6
version: 2.4.6(react@18.3.1)
hast-util-to-jsx-runtime: hast-util-to-jsx-runtime:
specifier: ^2.3.0 specifier: ^2.3.0
version: 2.3.0 version: 2.3.0
@ -1257,6 +1260,13 @@ packages:
'@types/unist': 3.0.2 '@types/unist': 3.0.2
dev: false dev: false
/@types/hoist-non-react-statics@3.3.5:
resolution: {integrity: sha512-SbcrWzkKBw2cdwRTwQAswfpB9g9LJWfjtUeW/jvNwbhC8cpmmNYVePa+ncbUe0rGTQ7G3Ff6mYUN2VMfLVr+Sg==}
dependencies:
'@types/react': 18.3.3
hoist-non-react-statics: 3.3.2
dev: false
/@types/katex@0.16.7: /@types/katex@0.16.7:
resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==} resolution: {integrity: sha512-HMwFiRujE5PjrgwHQ25+bsLJgowjGjm5Z8FVSf0N6PwgJrwxH0QxzHYDcKsTfV3wva0vzrpqMTJS2jXPr5BMEQ==}
dev: false dev: false
@ -1567,6 +1577,11 @@ packages:
character-entities: 2.0.2 character-entities: 2.0.2
dev: false dev: false
/deepmerge@2.2.1:
resolution: {integrity: sha512-R9hc1Xa/NOBi9WRVUWg19rl1UB7Tt4kuPd+thNJgFZoxXsTz7ncaPaeIm+40oSGuP33DfMb4sZt1QIGiJzC4EA==}
engines: {node: '>=0.10.0'}
dev: false
/dequal@2.0.3: /dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'} engines: {node: '>=6'}
@ -1674,6 +1689,22 @@ packages:
resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==}
dev: false dev: false
/formik@2.4.6(react@18.3.1):
resolution: {integrity: sha512-A+2EI7U7aG296q2TLGvNapDNTZp1khVt5Vk0Q/fyfSROss0V/V6+txt2aJnwEos44IxTCW/LYAi/zgWzlevj+g==}
peerDependencies:
react: '>=16.8.0'
dependencies:
'@types/hoist-non-react-statics': 3.3.5
deepmerge: 2.2.1
hoist-non-react-statics: 3.3.2
lodash: 4.17.21
lodash-es: 4.17.21
react: 18.3.1
react-fast-compare: 2.0.4
tiny-warning: 1.0.3
tslib: 2.6.2
dev: false
/fsevents@2.3.3: /fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -2101,6 +2132,14 @@ packages:
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
dev: false dev: false
/lodash-es@4.17.21:
resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==}
dev: false
/lodash@4.17.21:
resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==}
dev: false
/longest-streak@3.1.0: /longest-streak@3.1.0:
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
dev: false dev: false
@ -2713,6 +2752,10 @@ packages:
scheduler: 0.23.2 scheduler: 0.23.2
dev: false dev: false
/react-fast-compare@2.0.4:
resolution: {integrity: sha512-suNP+J1VU1MWFKcyt7RtjiSWUjvidmQSlqu+eHslq+342xCbGTYmC0mEhPCOHxlW0CywylOC1u2DFAT+bv4dBw==}
dev: false
/react-is@16.13.1: /react-is@16.13.1:
resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==}
dev: false dev: false
@ -3099,6 +3142,10 @@ packages:
resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==}
dev: false dev: false
/tiny-warning@1.0.3:
resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==}
dev: false
/to-fast-properties@2.0.0: /to-fast-properties@2.0.0:
resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
engines: {node: '>=4'} engines: {node: '>=4'}
@ -3122,6 +3169,10 @@ packages:
resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==}
dev: false dev: false
/tslib@2.6.2:
resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==}
dev: false
/typescript@5.4.5: /typescript@5.4.5:
resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==}
engines: {node: '>=14.17'} engines: {node: '>=14.17'}