omega restructuring + a bunch of changes i have made over the last weeks

This commit is contained in:
Sebastian Moser
2026-07-31 00:48:15 +02:00
parent c57849f748
commit b2ed9efff2
201 changed files with 4331 additions and 49073 deletions

671
marts/src/auth/client.rs Normal file
View File

@@ -0,0 +1,671 @@
use chrono::{DateTime, TimeDelta, Utc};
use mize::out;
use mize::{
item::ItemData, mize_err, mize_part, Mize, MizeError, MizePart, MizePartCreate, MizeResult,
};
use openidconnect::{
ClientId, DeviceAuthorizationResponse, EmptyExtraDeviceAuthorizationFields, IssuerUrl,
OAuth2TokenResponse, RefreshToken, Scope, TokenResponse,
};
use serde::{Deserialize, Serialize};
use std::fs::OpenOptions;
use std::io::Write;
use std::{path::PathBuf, thread};
use tracing::info;
// console_log macro
// that can be copied into other files for debugging purposes
#[cfg(feature = "target-obsidian")]
use wasm_bindgen::prelude::*;
#[cfg(feature = "target-obsidian")]
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
#[cfg(feature = "target-obsidian")]
macro_rules! console_log {
// Note that this is using the `log` function imported above during
// `bare_bones`
($($t:tt)*) => (unsafe { log(&format_args!($($t)*).to_string())})
}
#[cfg(feature = "target-os")]
use clap::Command;
#[derive(Default)]
#[mize_part]
pub struct ClientAuth {
mize: Mize,
secrets: Option<ClientSecrets>,
}
#[derive(Default, Serialize, Deserialize, Clone, Debug)]
pub struct ClientSecrets {
id_token: String,
access_token: String,
refresh_token: String,
expiry: DateTime<Utc>,
}
impl MizePartCreate for ClientAuth {}
impl MizePart for ClientAuth {
fn name(&self) -> &'static str {
"client_auth"
}
}
#[cfg(feature = "target-js")]
pub fn store_secrets_js(mize: &Mize, secrets: &ClientSecrets) -> MizeResult<()> {
use web_sys::window;
// Serialize struct to JSON string
let serialized = serde_json::to_string(&secrets)?;
// Get browser window object
let window = window().ok_or_else(|| mize_err!("No global window found"))?;
// Access local storage
let storage = window
.local_storage().map_err(|_| mize_err!("couldn't get local_storage"))?
.ok_or_else(|| mize_err!("LocalStorage is not available"))?;
// Store the JSON string
storage.set_item("mize_client_secrets", &serialized).map_err(|_| mize_err!("couldn't set item in local_storage"))?;
Ok(())
}
impl ClientAuth {
pub fn secrets(&self) -> Option<&ClientSecrets> {
self.secrets.as_ref()
}
pub fn id_token(&self) -> MizeResult<&str> {
if self.secrets.is_none() {
return Err(mize_err!("Client not logged in"));
}
Ok(&self.secrets.as_ref().unwrap().id_token)
}
pub fn access_token(&self) -> MizeResult<&str> {
if self.secrets.is_none() {
return Err(mize_err!("Client not logged in"));
}
Ok(&self.secrets.as_ref().unwrap().access_token)
}
pub fn refresh_token(&self) -> MizeResult<&str> {
if self.secrets.is_none() {
return Err(mize_err!("Client not logged in"));
}
Ok(&self.secrets.as_ref().unwrap().refresh_token)
}
pub fn refresh(&mut self) -> MizeResult<()> {
#[cfg(feature = "target-js")] {
return Err(mize_err!("refresh is not supported on wasm"));
}
#[cfg(feature = "target-os")] {
let secrets = self.secrets.clone().ok_or(mize_err!(
"no client secrets, meaning client_auth.refresh() thread can't use the refresh token"
))?;
let new_secrets = self.mize.spawn_async_blocking(
"auth.refresh_flow",
refresh_flow(self.mize.clone(), secrets),
)?;
store_secrets(&mut self.mize, &new_secrets)?;
self.secrets = Some(new_secrets);
}
Ok(())
}
pub fn login(&mut self) -> MizeResult<()> {
#[cfg(feature = "target-os")] {
if self.secrets().is_none() {
let secrets = self
.mize
.spawn_async_blocking("auth.login_flow", auth_flow(self.mize.clone()))?;
store_secrets(&mut self.mize, &secrets)?;
self.secrets = Some(secrets);
}
}
#[cfg(feature = "target-js")] {
if self.secrets().is_none() {
let mut mize_clone = self.mize.clone();
wasm_bindgen_futures::spawn_local(async move {
console_log!("before auth_flow");
let secrets = match auth_flow(mize_clone.clone()).await {
Ok(secrets) => secrets,
Err(e) => {
mize_clone.report_err(e);
return ();
}
};
console_log!("after auth_flow");
if let Err(e) = store_secrets_js(&mut mize_clone, &secrets) {
mize_clone.report_err(e);
return ();
}
let mut auth = match mize_clone.get_part_native::<ClientAuth>("client_auth") {
Ok(auth) => auth,
Err(e) => {
mize_clone.report_err(e);
return ();
}
};
auth.secrets = Some(secrets);
});
}
}
Ok(())
}
pub fn start_refresh_thread(&mut self) -> MizeResult<()> {
// background thread for os targets
#[cfg(not(feature = "target-js"))]
{
let mut mize_clone = self.mize.clone();
self.mize.spawn_background("auth.refresh", move || loop {
info!("hi from auth.refresh thread...");
thread::sleep(Duration::from_secs(33)); // TERRIBLE hack
let auth = mize_clone.get_part_native::<ClientAuth>("client_auth")?;
let expiry = auth.secrets.as_ref().unwrap().expiry.clone();
info!("auth.refresh thread: now: {}", Utc::now());
info!("auth.refresh thread: expiry: {}", expiry);
let to_sleep = expiry - Utc::now() - TimeDelta::minutes(20);
/*
if to_sleep < TimeDelta::from_std(Duration::from_secs(0))? {
if let Err(e) = auth.refresh() {
e.log();
}
}
*/
drop(auth);
info!("auth.refresh: thread going to sleep for: {}", to_sleep);
thread::sleep(to_sleep.to_std()?);
info!("refresh_thread: sleeping done");
let mut auth = mize_clone.get_part_native::<ClientAuth>("auth")?;
auth.refresh()?;
drop(auth);
})?;
Ok(())
}
// spawn an async background task doing the refresh
#[cfg(feature = "target-js")] {
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
// Get the window object
let window = web_sys::window().expect("no global `window` exists");
// Clone mize for the closure
let mize_clone = self.mize.clone();
// Create a closure that will be called periodically
let refresh_closure = Closure::wrap(Box::new(move || {
let mut mize_clone = mize_clone.clone();
wasm_bindgen_futures::spawn_local(async move {
let mut auth = mize_clone.get_part_native::<ClientAuth>("client_auth");
match auth {
Ok(mut auth_part) => {
if let Some(secrets) = auth_part.secrets() {
let now = Utc::now();
let expiry = secrets.expiry;
let time_until_expiry = expiry - now;
// Refresh if token will expire in less than 20 minutes
if time_until_expiry < TimeDelta::minutes(20) {
if let Err(e) = auth_part.refresh() {
console_log!("Error refreshing token: {:?}", e);
} else {
console_log!("Token refreshed successfully");
}
}
}
}
Err(e) => {
console_log!("Error getting auth part: {:?}", e);
}
}
});
}) as Box<dyn FnMut()>);
// Set up interval to run every 50 minutes (300000 ms)
let interval_ms = 3000000;
let _ = window.set_interval_with_callback_and_timeout_and_arguments_0(
refresh_closure.as_ref().unchecked_ref(),
interval_ms,
);
// Leak the closure to prevent it from being dropped
refresh_closure.forget();
Ok(())
}
}
}
pub fn part_client_auth(mize: &mut Mize) -> MizeResult<()> {
let mize_clone = mize.clone();
let mut auth_part = ClientAuth {
secrets: None,
mize: mize.clone(),
};
let mut secrets = match load_secrets(mize) {
Ok(secrets) => Some(secrets),
Err(err) => {
tracing::warn!("err reading secrets: {:?}", err);
None
}
};
auth_part.secrets = secrets.clone();
// do a refresh if the tokens have expired
if secrets.is_some() && secrets.clone().unwrap().expiry < Utc::now() {
auth_part.refresh()?;
}
// start refresh tread if configured
if mize
.get_config("auth.client.refresh_thread")
.unwrap_or(ItemData::new())
.value_bool()
.unwrap_or(false)
{
auth_part.start_refresh_thread()?;
}
// cli command for os
#[cfg(feature = "target-os")] {
let mut cli = mize.get_part_native::<crate::CliPart>("cli")?;
cli.set_command("ppc");
cli.subcommand(Command::new("login"), move |_, mut mize| {
let mut auth = mize.get_part_native::<ClientAuth>("client_auth")?;
let secrets = if auth.secrets.is_some() {
match mize.spawn_async_blocking(
"client_auth",
refresh_flow(mize.clone(), auth.secrets.clone().unwrap()),
) {
Ok(secrets) => secrets,
Err(err) => {
out!("err refreshing secrets: {:?}", err);
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))?
}
}
} else {
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone.clone()))?
};
store_secrets(&mut mize, &secrets)?;
auth.secrets = Some(secrets);
Ok(())
})?;
}
// register command for obsidian
#[cfg(feature = "target-obsidian")] {
use crate::MartsCommand;
let obsidian = mize.get_part_native::<crate::ObsidianPart>("obsidian")?;
obsidian.add_command(MartsCommand {
id: "mize-login".to_string(),
name: "Mize Login".to_string(),
callback: Box::new(|mut mize| {
let mut auth = mize.get_part_native::<crate::ClientAuth>("client_auth")?;
let res = auth.login();
console_log!("auth.login done: {:?}", res);
res
}),
})?;
}
auth_part.secrets = secrets;
mize.register_part(Box::new(auth_part))?;
Ok(())
}
fn load_secrets(mize: &mut Mize) -> MizeResult<ClientSecrets> {
#[cfg(feature = "target-os")] {
let mize_dir = mize.get_config("data_dir")?.value_string()?;
let secrets_file_path = PathBuf::from(mize_dir).join("client_secrets.json");
let secrets_string = std::fs::read_to_string(secrets_file_path)?;
let secrets: ClientSecrets = serde_json::from_str(&secrets_string)?;
return Ok(secrets);
}
#[cfg(feature = "target-js")] {
use web_sys::window;
// Get browser window object
let window = window().ok_or_else(|| mize_err!("No global window found"))?;
// Access local storage
let storage = window
.local_storage().map_err(|_| mize_err!("couldn't get local_storage"))?
.ok_or_else(|| mize_err!("LocalStorage is not available"))?;
// Retrieve the string item
return match storage.get_item("mize_client_secrets").map_err(|_| mize_err!("couldn't get item from local_storage"))? {
Some(json_str) => {
// Deserialize JSON string back into Client struct
let secrets: ClientSecrets = serde_json::from_str(json_str.as_str())?;
Ok(secrets)
}
None => Err(mize_err!("no secrets")), // Key does not exist
}
}
}
fn store_secrets(mize: &mut Mize, secrets: &ClientSecrets) -> MizeResult<()> {
#[cfg(feature = "target-os")] {
let mize_dir = mize.get_config("store")?.value_string()?;
let secrets_file_path = PathBuf::from(mize_dir.clone())
.join("misc")
.join("client_secrets.json");
std::fs::create_dir_all(PathBuf::from(mize_dir).join("misc"))?;
let secrets_string = serde_json::to_string(secrets)?;
log::info!("secrets_file_path: {}", secrets_file_path.display());
//std::fs::write(secrets_file_path, secrets_string)?;
OpenOptions::new()
.write(true)
.create(true)
.open(secrets_file_path)?
.write_all(&secrets_string.as_bytes())?;
// update the spacetimedb_token in XDG_CONFIG_HOME/spacetime/cli.toml config file if configured to do so
if mize
.get_config("auth.client.update_spacetime_token")
.unwrap_or(ItemData::empty())
.as_bool()
.unwrap_or(false)
{
update_spacetimedb_token(&secrets.id_token.as_str())?;
}
}
Ok(())
}
use openidconnect::reqwest::Client as HttpClient;
use openidconnect::DeviceAuthorizationUrl;
use std::time::Duration;
//use wasm_bindgen::JsCast;
async fn refresh_flow(mut mize: Mize, old_secrets: ClientSecrets) -> MizeResult<ClientSecrets> {
out!("Refresh flow...");
let issuer = mize.get_config("auth.client.issuer")?.value_string()?;
let client_id = mize.get_config("auth.client.client_id")?.value_string()?;
let issuer = IssuerUrl::new(issuer)?;
let client_id = ClientId::new(client_id);
let http_client = HttpClient::new();
// Teach openidconnect-rs about the device authorization url.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct MyExtraProviderMetadata {
device_authorization_endpoint: String,
}
use openidconnect::core::{
CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod,
CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType,
CoreSubjectIdentifierType,
};
use openidconnect::AdditionalProviderMetadata;
use openidconnect::ProviderMetadata;
impl AdditionalProviderMetadata for MyExtraProviderMetadata {}
type MyProviderMetadata = ProviderMetadata<
MyExtraProviderMetadata,
CoreAuthDisplay,
CoreClientAuthMethod,
CoreClaimName,
CoreClaimType,
CoreGrantType,
CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm,
CoreJsonWebKey,
CoreResponseMode,
CoreResponseType,
CoreSubjectIdentifierType,
>;
// Discover provider metadata
let provider_metadata: MyProviderMetadata =
MyProviderMetadata::discover_async(issuer, &http_client).await?;
let device_auth_url = provider_metadata
.additional_metadata()
.device_authorization_endpoint
.clone();
let client = CoreClient::from_provider_metadata(
provider_metadata,
client_id,
None, // public client
)
.set_device_authorization_url(DeviceAuthorizationUrl::new(device_auth_url)?);
let refresh_token = RefreshToken::new(old_secrets.refresh_token);
let res = client
.exchange_refresh_token(&refresh_token)?
.request_async(&http_client)
.await?;
let expires_in = res.expires_in().ok_or(mize_err!(
"no expiry information was in token response of the refresh flow"
))?;
return Ok(ClientSecrets {
id_token: res.id_token().unwrap().to_string(),
access_token: res.access_token().secret().to_owned(),
refresh_token: res.refresh_token().unwrap().secret().to_owned(),
expiry: Utc::now() + expires_in,
});
}
async fn auth_flow(mut mize: Mize) -> MizeResult<ClientSecrets> {
let issuer = mize.get_config("auth.client.issuer")?.value_string()?;
let client_id = mize.get_config("auth.client.client_id")?.value_string()?;
let issuer = IssuerUrl::new(issuer)?;
let client_id = ClientId::new(client_id);
let http_client = HttpClient::new();
// Teach openidconnect-rs about the device authorization url.
#[derive(Clone, Debug, Deserialize, Serialize)]
struct MyExtraProviderMetadata {
device_authorization_endpoint: String,
}
use openidconnect::core::{
CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClient, CoreClientAuthMethod,
CoreGrantType, CoreJsonWebKey, CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm, CoreResponseMode, CoreResponseType,
CoreSubjectIdentifierType,
};
use openidconnect::AdditionalProviderMetadata;
use openidconnect::ProviderMetadata;
impl AdditionalProviderMetadata for MyExtraProviderMetadata {}
type MyProviderMetadata = ProviderMetadata<
MyExtraProviderMetadata,
CoreAuthDisplay,
CoreClientAuthMethod,
CoreClaimName,
CoreClaimType,
CoreGrantType,
CoreJweContentEncryptionAlgorithm,
CoreJweKeyManagementAlgorithm,
CoreJsonWebKey,
CoreResponseMode,
CoreResponseType,
CoreSubjectIdentifierType,
>;
// Discover provider metadata
let provider_metadata: MyProviderMetadata =
MyProviderMetadata::discover_async(issuer, &http_client).await?;
let device_auth_url = provider_metadata
.additional_metadata()
.device_authorization_endpoint
.clone();
let client = CoreClient::from_provider_metadata(
provider_metadata,
client_id,
None, // public client
)
.set_device_authorization_url(DeviceAuthorizationUrl::new(device_auth_url)?);
// ===== DEVICE AUTH REQUEST =====
let details: Result<DeviceAuthorizationResponse<EmptyExtraDeviceAuthorizationFields>, _> =
client
.exchange_device_code()
.add_scope(Scope::new("openid".into()))
.add_scope(Scope::new("profile".into()))
.add_scope(Scope::new("offline_access".into()))
.request_async(&http_client)
.await;
let details = match details {
Ok(val) => val,
Err(err) => {
out!("err: {:?}", err);
out!("err: {}", err);
panic!();
}
};
#[cfg(feature = "target-obsidian")] {
console_log!("details: {:?}", details);
use std::str::FromStr;
use js_sys::{Array, Function, JsString, Reflect};
use mize::error::IntoMizeResultJsValue;
use wasm_bindgen::convert::TryFromJsValue;
let obsidian = mize.get_part_native::<crate::ObsidianPart>("obsidian")?;
let show_device_code = Reflect::get(&obsidian.plugin(), &JsValue::from_str("showDeviceCode")).mres()?;
let args = Array::new();
args.push(&JsValue::from_str(details.user_code().secret().as_str()));
args.push(&JsValue::from_str(details.verification_uri_complete().unwrap().secret().as_str()));
Reflect::apply(&show_device_code.dyn_into::<Function>().mres()?, obsidian.plugin(), &args).mres()?;
}
#[cfg(feature = "target-os")] {
out!(
"If your browser does not open automatically, open this URL in it:\n{}\n",
details.verification_uri_complete().unwrap().secret()
);
out!(
"Or enter this PPC login code:\n{}\n",
details.user_code().secret()
);
if let Some(_) = details.verification_uri_complete() {
let _ = webbrowser::open(details.verification_uri_complete().unwrap().secret());
}
}
let token_response = client
.exchange_device_access_token(&details)?
.request_async(
&http_client,
async |dur: Duration| {
#[cfg(feature = "target-os")] {
tokio::time::sleep(dur).await;
}
#[cfg(feature = "target-js")] {
console_log!("sleeping");
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::JsFuture;
let promise = js_sys::Promise::new(&mut |resolve, _| {
web_sys::window()
.unwrap()
.set_timeout_with_callback_and_timeout_and_arguments_0(&resolve, dur.as_millis() as i32)
.unwrap();
});
// Await the JS Promise like a Rust Future
let _ = JsFuture::from(promise).await;
}
},
None,
)
.await?;
// ===== TOKENS =====
let id_token = token_response
.extra_fields()
.id_token()
.ok_or(mize_err!("no id_token"))?;
let refresh = token_response
.refresh_token()
.ok_or(mize_err!("no refresh token present"))?;
let expires_in = token_response.expires_in().ok_or(mize_err!(
"no expiry information was in token response of the refresh flow"
))?;
Ok(ClientSecrets {
id_token: id_token.to_string(),
access_token: token_response.access_token().secret().to_string(),
refresh_token: refresh.secret().to_owned(),
expiry: Utc::now() + expires_in,
})
}
fn update_spacetimedb_token(new_token: &str) -> MizeResult<()> {
// Resolve config directory
let config_dir = match std::env::var("XDG_CONFIG_HOME") {
Ok(val) => PathBuf::from(val),
Err(_) => {
let home = std::env::var("HOME")?;
std::path::PathBuf::from(home).join(".config")
}
};
let file_path = config_dir.join("spacetime").join("cli.toml");
// Read existing file (or create empty document if it doesn't exist)
let content = std::fs::read_to_string(&file_path).unwrap_or_default();
let mut data = ItemData::from_toml(content.as_str())?;
// Update the field
data.set_path("spacetimedb_token", new_token)?;
// Write back
std::fs::write(&file_path, data.to_toml()?)?;
Ok(())
}

View File

@@ -0,0 +1,9 @@
use dioxus::prelude::*;
pub fn main() -> Element {
rsx! {
div {
h1 { "my dashbaord" }
}
}
}

431
marts/src/c2vi/deno.ts Normal file
View File

@@ -0,0 +1,431 @@
import { createClient } from "@libsql/client";
import { drizzle } from "drizzle-orm/libsql";
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
import { eq } from "drizzle-orm";
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { spawnSync } from "node:child_process";
export async function init(ppc: PPC) {
ppc.c2vi = new C2vi(ppc);
ppc.cli
.command("bed", "command to run when c2vi goes to bed")
.action(async () => {
await save_habitica_dailies(ppc);
await save_habitica_logs(ppc);
});
ppc.cli
.command("clearTodos", "command to run when c2vi goes to bed")
.alias("clt")
.action(async () => {
const todos = await ppc.habitica.get_tasks();
for (const todo of todos) {
await ppc.habitica.delete_task(todo.id);
}
});
ppc.cli
.command("listTodos", "command to run when c2vi goes to bed")
.alias("dut")
.action(async () => {
const todos = await ppc.habitica.get_tasks();
for (const todo of todos) {
console.log(todo.text);
}
});
ppc.cli
.command("buyHealthPotion", "command to run when c2vi goes to bed")
.alias("buyh")
.action(async () => {
await ppc.habitica.api_request("POST", "/user/buy-health-potion");
});
ppc.cli
.command("addActionItems", "command to run when c2vi goes to bed")
.alias("aal")
.action(async () => {
await add_action_items(ppc);
});
ppc.cli
.command("test", "a command to run some test code")
.alias("t")
.action(async () => {});
ppc.cli
.command("skip [num]", "command to skip a habitica todo")
.alias("sk")
.action((num = 1) => {
skip_habitica_todo(ppc, parseInt(num));
});
ppc.cli.command("dumpLog", "dump the complete habitica log").action(() => {
dump_habitica_logs(ppc);
});
ppc.cli
.command("listDailies", "list all dailies")
.action(async (folder, options) => {
const allDailies = await ppc.c2vi.db.select().from(dailiesTable).all();
for (const task of allDailies) {
console.log(task.id + ": " + task.text);
}
});
ppc.cli
.command("printTask <id>", "print a specific todo")
.action(async (id) => {
const data = await ppc.habitica.api_request("GET", `/tasks/${id}`, {});
console.log(data.history);
});
}
class C2vi {
[key: string]: any;
constructor(ppc: PPC) {
this.ppc = ppc;
this.db = drizzle(
createClient({
url: "file://" + ppc.config.local_storage_path + "/data.db",
}),
);
}
}
function formatDate(d) {
const pad = (n) => n.toString().padStart(2, "0");
// Custom YYYY-MM-DD format
const formatted = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
return formatted;
}
// ============= SCHEMA DEFINITION =============
const dailiesTable = sqliteTable("habitica_dailies", {
id: text("id").primaryKey(),
text: text("text").notNull(),
notes: text("notes"),
priority: text("priority"), // 0.1, 1, 1.5, 2
frequency: text("frequency"), // 'daily', 'weekly', etc.
});
const habiticaLogTable = sqliteTable("habitica_log", {
date: text("date").primaryKey(),
data: text("data").notNull(),
});
async function save_habitica_dailies(ppc) {
console.log("######### cli bed cmd #########");
await ppc.c2vi.db.run(
"CREATE TABLE IF NOT EXISTS habitica_dailies (id TEXT PRIMARY KEY, text TEXT NOT NULL, notes TEXT, priority TEXT, frequency TEXT)",
);
await ppc.c2vi.db.run(
"CREATE TABLE IF NOT EXISTS habitica_log (date TEXT PRIMARY KEY, data TEXT NOT NULL)",
);
// ============= DATABASE INITIALIZATION =============
if (!ppc.config.habitica.user_id || !ppc.config.habitica.api_token) {
console.error(
"Error: HABITICA_USER_ID and HABITICA_API_TOKEN env vars required",
);
console.error(
"Get these from Habitica Settings > API and configure them in the ppc.config",
);
process.exit(1);
}
const dailies = await ppc.habitica.get_tasks("dailys");
await writeDailiesToDatabase(dailies, ppc);
}
async function dump_habitica_logs(ppc) {
const entries = await ppc.c2vi.db.select().from(habiticaLogTable).all();
for (const entry of entries) {
console.log(entry.date + ": ", JSON.parse(entry.data));
}
}
async function skip_habitica_todo(ppc, num) {
await ppc.c2vi.db.run(
"CREATE TABLE IF NOT EXISTS habitica_dailies (id TEXT PRIMARY KEY, text TEXT NOT NULL, notes TEXT, priority TEXT, frequency TEXT)",
);
await ppc.c2vi.db.run(
"CREATE TABLE IF NOT EXISTS habitica_log (date TEXT PRIMARY KEY, data TEXT NOT NULL)",
);
const todos = await ppc.habitica.get_tasks("todos");
const date = new Date();
const save_for_yesterday = date.getHours() < 12;
if (save_for_yesterday) {
console.log(
"Saving logs for yesterday!!!! because it is " +
date.getHours() +
"hours of the day",
);
date.setDate(date.getDate() - 1);
}
const result = await ppc.c2vi.db
.select()
.from(habiticaLogTable)
.where(eq(habiticaLogTable.date, formatDate(date)));
const data = result[0]
? JSON.parse(result[0].data)
: {
dailies_done: [],
dailies_skipped: [],
todos_done: [],
todos_skipped: [],
};
for (let i = 0; i < num; i++) {
const todo = todos[i];
if (!todo) continue;
await ppc.habitica.delete_task(todo.id);
await incrementHabit(ppc);
data.todos_skipped.push({
id: todo.id,
text: todo.text,
notes: todo.notes || "",
checklist: todo.checklist || [],
tags: todo.tags || [],
});
}
// saving to db
await ppc.c2vi.db
.insert(habiticaLogTable)
.values({ date: formatDate(date), data: JSON.stringify(data) })
.onConflictDoUpdate({
target: habiticaLogTable.date,
set: {
data: JSON.stringify(data),
},
});
}
async function save_habitica_logs(ppc) {
const date = new Date();
const save_for_yesterday = date.getHours() < 12;
if (save_for_yesterday) {
console.log(
"Saving logs for yesterday!!!! because it is " +
date.getHours() +
"hours of the day",
);
date.setDate(date.getDate() - 1);
}
const result = await ppc.c2vi.db
.select()
.from(habiticaLogTable)
.where(eq(habiticaLogTable.date, formatDate(date)));
const data = result[0]
? JSON.parse(result[0].data)
: {
dailies_done: [],
dailies_skipped: [],
todos_done: [],
todos_skipped: [],
};
// dailies
const dailies = await ppc.habitica.get_tasks("dailys");
for (const daily of dailies) {
let completed = daily.completed;
let isDue = daily.isDue;
if (save_for_yesterday) {
const yesterday = daily.history.reduce((prev, current) => {
return prev.date > current.date ? prev : current;
});
completed = yesterday.completed;
isDue = yesterday.isDue;
}
if (!isDue) {
continue;
}
if (completed) {
data.dailies_done.push(daily.id);
console.log("daily-done: " + daily.text);
} else {
data.dailies_skipped.push(daily.id);
console.log("daily-skipped: " + daily.text);
}
}
// todos done
const todos = await ppc.habitica.get_tasks("completedTodos");
for (const todo of todos) {
const dateCompleted = new Date(todo.dateCompleted);
// check if this todo is already saved in yesterday's log entry
if (save_for_yesterday) {
// get yesterday log entry
const result = await ppc.c2vi.db
.select()
.from(habiticaLogTable)
.where(eq(habiticaLogTable.date, formatDate(date)));
const data = result[0]
? JSON.parse(result[0].data)
: {
dailies_done: [],
dailies_skipped: [],
todos_done: [],
todos_skipped: [],
};
// check if this todo is already saved in yesterday's log entry
const todoIndex = data.todos_done.findIndex((t) => t.id === todo.id);
if (todoIndex !== -1) {
continue; // skipp saving this todo
}
}
if (dateCompleted.toDateString() == date.toDateString()) {
console.log("todo-done:", todo.text);
data.todos_done.push({
id: todo.id,
text: todo.text,
notes: todo.notes || "",
checklist: todo.checklist || [],
tags: todo.tags || [],
});
}
// we save_for_yesterday but the todo was completed today... also save that for this day
if (
save_for_yesterday &&
dateCompleted.toDateString() == new Date().toDateString()
) {
console.log("todo-done:", todo.text);
data.todos_done.push({
id: todo.id,
text: todo.text,
notes: todo.notes || "",
checklist: todo.checklist || [],
tags: todo.tags || [],
});
}
}
// saving to db
await ppc.c2vi.db
.insert(habiticaLogTable)
.values({ date: formatDate(date), data: JSON.stringify(data) })
.onConflictDoUpdate({
target: habiticaLogTable.date,
set: {
data: JSON.stringify(data),
},
});
}
async function writeDailiesToDatabase(dailies, ppc) {
console.log(`Syncing ${dailies.length} dailies to database...`);
const records = dailies.map((daily) => ({
id: daily.id,
text: daily.text,
notes: daily.notes || "",
priority: daily.priority?.toString() || null,
frequency: daily.frequency || "daily",
}));
async function upsertMany(records) {
for (const record of records) {
await ppc.c2vi.db
.insert(dailiesTable)
.values(record)
.onConflictDoUpdate({
target: dailiesTable.id,
set: {
text: record.text,
notes: record.notes,
priority: record.priority,
frequency: record.frequency,
},
});
}
}
await upsertMany(records);
}
async function incrementHabit(ppc) {
const habits = await ppc.habitica.get_tasks("habits");
let id = "";
for (const habit of habits) {
if ((habit.text = "skipped a planned task")) {
id = habit.id;
}
}
await ppc.habitica.api_request("POST", `tasks/${id}/score/down`);
}
async function add_action_items(ppc: PPC) {
let task_ids = [];
const tmpFile = path.join(os.tmpdir(), `habitica-tasks-${Date.now()}.md`);
const initialContent = `
gu 10min TIMER
uw 15min FROG
b coffee/tee/kakau
uw FROG
b breakfast, brush teeth, lüften
uw FROG
lunch
uw 15min TIMER of next days FROG
fw
dt 30min TIMER
fun 20:00 (smth that can be stopped easily)
se 21:40
read
bed 22:10
`;
fs.writeFileSync(tmpFile, initialContent.trim());
spawnSync("nvim", [tmpFile], { stdio: "inherit" });
// read file
const listStr = fs.readFileSync(tmpFile, "utf8");
// delete file
fs.unlinkSync(tmpFile);
// Simple parser: filter out empty lines
const tasks = listStr
.split("\n")
.map((t) => t.trim())
.filter((t) => t.length > 0);
// 5. Add tasks to Habitica
for (const todoText of tasks) {
const data = await ppc.habitica.api_request(
"POST",
"tasks/user",
{},
{ text: todoText, type: "todo" },
);
task_ids.push(data.id);
console.log(`Added to-do: '${todoText}' with id '${data.id}'`);
}
// 6. Move tasks to bottom
for (const taskId of task_ids) {
await ppc.habitica.api_request("POST", `tasks/${taskId}/move/to/-1`);
}
}

View File

@@ -0,0 +1,170 @@
export function c2vi_obsidian_canvas_patch(mize: any) {
const plugin = mize.get_part_native("obsidian");
window.pc = patchCanvas;
window.upc = () => {
unpatchCanvas(plugin, plugin.app.workspace.activeLeaf);
};
console.log("hereeeeeeeeeeeeeeeee");
// canvas patching
plugin.registerEvent(
plugin.app.workspace.on("file-open", (file: any) => {
if (file?.extension === "canvas") {
maybePathCanvas(plugin, plugin.app.workspace.activeLeaf);
}
}),
);
plugin.registerEvent(
plugin.app.workspace.on("active-leaf-change", (file: any) => {
if (file?.extension === "canvas") {
maybePathCanvas(plugin, plugin.app.workspace.activeLeaf);
}
}),
);
}
const maybePathCanvas = (plugin: PPCObsidianPlugin, leaf: any | null) => {
console.log("maybePathCanvas called");
const file = plugin.app.workspace.getActiveFile();
if (file?.extension !== "canvas") return;
console.log("patching canvas.....");
const canvas = plugin.app.workspace.activeLeaf.view.canvas;
if ("isPatched" in canvas) return;
patchCanvas(plugin, leaf, canvas);
};
function patchCanvas(plugin: PPCObsidianPlugin, leaf: any, canvas: any) {
canvas.isPatched = true;
const canvasEl = canvas.canvasEl;
const canvasWrapperEl = canvasEl.parentElement;
const overlay = createDiv();
overlay.setAttr("class", "c2vi-canvas-overlay");
const canvas_card_menue = canvasWrapperEl.childNodes[1];
canvasWrapperEl.insertBefore(overlay, canvas_card_menue);
Object.assign(overlay.style, {
position: "absolute",
inset: "0",
//zIndex: "1337",
});
overlay.addEventListener("contextmenu", (e) => {
if (e.button === 2) {
e.preventDefault();
e.stopPropagation();
e.stopImmediatePropagation();
}
});
overlay.addEventListener("mouseout", (e) => {
const menu = document.getElementsByClassName("menu")[0];
if (!menu) return;
menu.addEventListener("pointerup", (e) => {
console.log("pointerup on menu", e);
e.target.click();
/*
menu.dispatchEvent(
new MouseEvent("mousedown", {
bubbles: true,
cancelable: true,
composed: true,
clientX: e.clientX,
clientY: e.clientY,
button: e.button,
buttons: e.buttons,
view: window,
}),
);
*/
});
});
setupEventPropagation("pointerdown", PointerEvent, overlay, canvasWrapperEl);
setupEventPropagation("pointerup", PointerEvent, overlay, canvasWrapperEl);
setupEventPropagation("mousedown", MouseEvent, overlay, canvasWrapperEl);
setupEventPropagation("mouseup", MouseEvent, overlay, canvasWrapperEl);
setupEventPropagation("click", MouseEvent, overlay, canvasWrapperEl);
setupEventPropagation("dragstart", DragEvent, overlay, canvasWrapperEl);
setupEventPropagation("dragstop", DragEvent, overlay, canvasWrapperEl);
window.a = canvasWrapperEl;
window.b = overlay;
const mouseEvents = [
"mousedown",
"mouseup",
"click",
"dblclick",
//"mousemove",
"mouseover",
"mouseout",
"wheel",
];
mouseEvents.forEach((eventType) => {
canvasWrapperEl.addEventListener(
eventType,
(event) => {
console.log(`Mouse Event Triggered: ${event.type}`, event);
},
{ passive: true },
);
});
}
function unpatchCanvas(plugin: PPCObsidianPlugin, leaf: any) {
const canvas = plugin.app.workspace.activeLeaf.view.canvas;
if (!canvas.isPatched) return;
canvas.isPatched = false;
const canvasEl = canvas.canvasEl;
const canvasWrapperEl = canvasEl.parentElement;
const targets = canvasEl.querySelectorAll(":scope > .c2vi-canvas-overlay");
targets.forEach((child) => child.remove());
}
function setupEventPropagation(type: string, evClass, overlay: any, target) {
overlay.addEventListener(type, (originalEvent) => {
originalEvent.stopPropagation();
originalEvent.stopImmediatePropagation();
originalEvent.preventDefault();
const newEvent = new evClass(type, originalEvent);
if (type == "pointerdown" && originalEvent.ctrlKey == true) {
dispatchContextMenue(
target,
originalEvent.clientX,
originalEvent.clientY,
);
return;
}
if (type === "pointerdown") {
window.downEv = newEvent;
}
if (type === "pointerup") {
window.upEv = newEvent;
}
newEvent.isTrusted = true;
console.log("original Event of", type, originalEvent);
console.log("new Event of", type, newEvent);
target.dispatchEvent(newEvent);
});
}
function dispatchContextMenue(target, x, y) {
const newEvent = new PointerEvent("contextmenu", {
bubbles: true,
cancelable: true,
composed: true,
view: window,
clientX: x,
clientY: y,
pointerType: "mouse",
button: 2,
buttons: 2,
pointerId: 1,
isPrimary: true,
});
target.dispatchEvent(newEvent);
}

693
marts/src/c2vi/part.rs Normal file
View File

@@ -0,0 +1,693 @@
use std::sync::Mutex;
use chrono::{Timelike, Utc};
use clap::{Arg, Command};
#[cfg(feature = "desktop")]
use mize::item::ItemData;
use rusqlite::{params, Connection};
use serde_json::{json, Value};
use dioxus::prelude::*;
use mize::{mize_err, mize_part, Mize, MizeError, MizePart, MizePartCreate, MizeResult};
use tracing::instrument::WithSubscriber;
use crate::cli::CliPart;
use crate::habitica::Habitica;
use crate::SpacetimePart;
use generated::spacetime::{
Action, ActivitiesTableAccess, Activity, DbConnection, LogEventsTableAccess, TaskPart, TaskPartTableAccess, activitiesQueryTableAccess, next_action, remove_activity, task_partQueryTableAccess, update_activity, update_upcoming
};
use spacetimedb_sdk::{
credentials, DbContext, Error, Event, Identity, Status, SubscriptionHandle, Table,
TableWithPrimaryKey,
};
#[mize_part]
#[derive(Default)]
pub struct C2vi {
mize: Mize,
db: Mutex<Option<Connection>>,
}
impl MizePart for C2vi {
fn opts(&self, mize: &mut Mize) {
mize.new_opt("c2vi.local_storage_path");
}
}
impl MizePartCreate for C2vi {}
impl C2vi {
fn with_db<F, R>(&self, f: F) -> MizeResult<R>
where
F: FnOnce(&Connection) -> MizeResult<R>,
{
let guard = self
.db
.lock()
.map_err(|e| mize_err!("DB lock poisoned: {}", e))?;
let conn = guard
.as_ref()
.ok_or_else(|| mize_err!("DB not initialized"))?;
f(conn)
}
}
pub fn c2vi(mize: &mut Mize) -> MizeResult<()> {
#[cfg(feature = "desktop")]
if mize
.get_config("c2vi.dash.enable")
.unwrap_or(ItemData::empty())
.as_bool()
.unwrap_or(false)
{
launch_c2vi_dashboard(mize);
}
Ok(())
}
#[cfg(feature = "desktop")]
pub fn launch_c2vi_dashboard(mize: &mut Mize) {
use dioxus::desktop::{muda::Menu, Config, WindowBuilder};
let mize_clone = mize.clone();
LaunchBuilder::new()
.with_context_provider(move || Box::new(mize_clone.clone()))
.with_cfg(desktop! {
Config::new().with_window(WindowBuilder::new().with_decorations(false).with_title("PPC"))
})
.launch(c2vi_dash_main);
}
//use spacetimedb_sdk::spacetime_module::QueryBuilder;
/*
pub fn use_spacetime<T: Fn(QueryBuilder)>(query: T) {
let mut mize = use_context::<Mize>();
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime").unwrap();
let con = spacetime.con();
con.subscription_builder()
.on_applied(move |ctx| {
let data = ctx.db.task_part().identity().find(&ctx.identity()).unwrap();
current.set(data.current.name.clone());
})
.add_query(query)
.subscribe();
con.db
.task_part()
.on_update(move |_, old: &TaskPart, new: &TaskPart| {
current.set(new.current.name.clone());
});
}
*/
//pub fn use_by_pk(identity: Identity)
//pub fn use_one<T>
pub fn c2vi_dash_main() -> Element {
let mut current = use_signal_sync(|| "".to_string());
//TaskPart::
//use_one()
use_effect(move || {
let mut mize = use_context::<Mize>();
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime").unwrap();
let con = spacetime.con();
con.subscription_builder()
.on_applied(move |ctx| {
let data = ctx.db.task_part().identity().find(&ctx.identity()).unwrap();
current.set(data.current.name.clone());
})
.add_query(|q| q.from.task_part().r#where(|t| t.identity.eq(con.identity())))
.subscribe();
con.db
.task_part()
.on_update(move |_, old: &TaskPart, new: &TaskPart| {
current.set(new.current.name.clone());
});
});
rsx! {
div {
style: "text-align: center; padding: 2rem; font-size: 50px;",
p {
"{current}"
}
}
}
}
pub fn c2vi_client(mize: &mut Mize) -> MizeResult<()> {
//let storage_path = mize.get_config("c2vi.local_storage_path")?.as_
let storage_path = "/home/me/work/app-data/ppc";
//println!("storage_path: {}", storage_path);
let db_path = std::path::PathBuf::from(&storage_path).join("data.db");
let conn = Connection::open(&db_path)
.map_err(|e| mize_err!("Failed to open C2vi database at {:?}: {}", db_path, e))?;
setup_tables(&conn)?;
let c2vi_part = C2vi {
mize: mize.clone(),
db: Mutex::new(Some(conn)),
};
mize.register_part(Box::new(c2vi_part))?;
// Register CLI subcommands
let mut cli = mize.get_part_native::<CliPart>("cli")?;
register_subcommands(&mut cli);
Ok(())
}
fn setup_tables(conn: &Connection) -> MizeResult<()> {
conn.execute(
"CREATE TABLE IF NOT EXISTS habitica_dailies (
id TEXT PRIMARY KEY,
text TEXT NOT NULL,
notes TEXT,
priority TEXT,
frequency TEXT
)",
[],
)
.map_err(|e| mize_err!("DB error creating habitica_dailies: {}", e))?;
conn.execute(
"CREATE TABLE IF NOT EXISTS habitica_log (
date TEXT PRIMARY KEY,
data TEXT NOT NULL
)",
[],
)
.map_err(|e| mize_err!("DB error creating habitica_log: {}", e))?;
Ok(())
}
fn register_subcommands(cli: &mut CliPart) {
cli.subcommand(
Command::new("c2vi-dash").about("Run the c2vi dashboard gui"),
|_matches, mut _mize| Ok(dioxus::launch(super::dashboard::main)),
);
cli.subcommand(
Command::new("bed").about("Command to run when c2vi goes to bed"),
|_matches, mut mize| {
save_habitica_dailies(&mut mize)?;
save_habitica_logs(&mut mize)?;
Ok(())
},
);
cli.subcommand(
Command::new("clearTodos")
.about("Clear all Habitica todos")
.alias("clt"),
|_matches, mut mize| {
let todos = {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
hab.get_tasks("todos")?
};
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
if let Some(arr) = todos.as_array() {
for todo in arr {
if let Some(id) = todo["id"].as_str() {
hab.delete_task(id)?;
}
}
}
Ok(())
},
);
cli.subcommand(
Command::new("listTodos")
.about("List all Habitica todos")
.alias("dut"),
|_matches, mut mize| {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
let todos = hab.get_tasks("todos")?;
if let Some(arr) = todos.as_array() {
for todo in arr {
if let Some(text) = todo["text"].as_str() {
println!("{}", text);
}
}
}
Ok(())
},
);
cli.subcommand(
Command::new("buyHealthPotion")
.about("Buy a health potion")
.alias("buyh"),
|_matches, mut mize| {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
hab.api_request(
reqwest::Method::POST,
"user/buy-health-potion".to_string(),
json!({}),
)?;
Ok(())
},
);
cli.subcommand(
Command::new("addActionItems")
.about("Add action items from temp file")
.alias("aal"),
|_matches, mut mize| add_action_items(&mut mize),
);
cli.subcommand(
Command::new("skip")
.about("Skip a Habitica todo")
.alias("sk")
.arg(Arg::new("num").default_value("1")),
|matches, mut mize| {
let num: usize = matches
.get_one::<String>("num")
.unwrap()
.parse()
.unwrap_or(1);
skip_habitica_todo(&mut mize, num)
},
);
cli.subcommand(
Command::new("dumpLog").about("Dump the complete Habitica log"),
|_matches, mut mize| dump_habitica_logs(&mut mize),
);
cli.subcommand(
Command::new("listDailies").about("List all dailies from local DB"),
|_matches, mut mize| {
let c2vi = mize.get_part_native::<C2vi>("c2vi")?;
c2vi.with_db(|conn| {
let mut stmt = conn
.prepare("SELECT id, text FROM habitica_dailies")
.map_err(|e| mize_err!("DB error: {}", e))?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| mize_err!("DB error: {}", e))?;
for row in rows {
let (id, text) = row.map_err(|e| mize_err!("DB error: {}", e))?;
println!("{}: {}", id, text);
}
Ok(())
})
},
);
cli.subcommand(
Command::new("printTask")
.about("Print a specific task from Habitica")
.arg(Arg::new("id").required(true)),
|matches, mut mize| {
let id = matches.get_one::<String>("id").unwrap();
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
let data = hab.api_request(reqwest::Method::GET, format!("tasks/{}", id), json!({}))?;
if let Some(history) = data.get("history") {
println!(
"{}",
serde_json::to_string_pretty(history).unwrap_or_default()
);
} else {
println!(
"{}",
serde_json::to_string_pretty(&data).unwrap_or_default()
);
}
Ok(())
},
);
}
// ============= Business Logic =============
fn format_date(dt: chrono::DateTime<Utc>) -> String {
dt.format("%Y-%m-%d").to_string()
}
/// Get the target date, adjusted for "save for yesterday" logic (before noon = yesterday)
fn get_target_date() -> (String, bool) {
let now = Utc::now();
let save_for_yesterday = now.hour() < 12;
let date = if save_for_yesterday {
now - chrono::Duration::days(1)
} else {
now
};
if save_for_yesterday {
println!(
"Saving logs for yesterday because it is {} hours of the day",
now.hour()
);
}
(format_date(date), save_for_yesterday)
}
/// Load existing log entry from DB, or return a default empty one
fn get_or_create_log(conn: &Connection, date: &str) -> MizeResult<Value> {
let mut stmt = conn
.prepare("SELECT data FROM habitica_log WHERE date = ?1")
.map_err(|e| mize_err!("DB error: {}", e))?;
let result: Option<String> = stmt.query_row(params![date], |row| row.get(0)).ok();
match result {
Some(data_str) => {
serde_json::from_str(&data_str).map_err(|e| mize_err!("JSON parse error: {}", e))
}
None => Ok(json!({
"dailies_done": [],
"dailies_skipped": [],
"todos_done": [],
"todos_skipped": []
})),
}
}
/// Upsert a log entry into the database
fn upsert_log(conn: &Connection, date: &str, data: &Value) -> MizeResult<()> {
let data_str =
serde_json::to_string(data).map_err(|e| mize_err!("JSON serialize error: {}", e))?;
conn.execute(
"INSERT INTO habitica_log (date, data) VALUES (?1, ?2)
ON CONFLICT(date) DO UPDATE SET data=excluded.data",
params![date, data_str],
)
.map_err(|e| mize_err!("DB error upserting log: {}", e))?;
Ok(())
}
fn save_habitica_dailies(mize: &mut Mize) -> MizeResult<()> {
println!("######### cli bed cmd #########");
// Fetch dailies from Habitica
let dailies = {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
hab.get_tasks("dailys")?
};
// Write to database
let c2vi = mize.get_part_native::<C2vi>("c2vi")?;
c2vi.with_db(|conn| {
if let Some(arr) = dailies.as_array() {
println!("Syncing {} dailies to database...", arr.len());
for daily in arr {
let id = daily["id"].as_str().unwrap_or("");
let text = daily["text"].as_str().unwrap_or("");
let notes = daily["notes"].as_str().unwrap_or("");
let priority = daily["priority"].to_string();
let frequency = daily["frequency"].as_str().unwrap_or("daily");
conn.execute(
"INSERT INTO habitica_dailies (id, text, notes, priority, frequency)
VALUES (?1, ?2, ?3, ?4, ?5)
ON CONFLICT(id) DO UPDATE SET
text=excluded.text,
notes=excluded.notes,
priority=excluded.priority,
frequency=excluded.frequency",
params![id, text, notes, priority, frequency],
)
.map_err(|e| mize_err!("DB error writing daily: {}", e))?;
}
}
Ok(())
})
}
fn save_habitica_logs(mize: &mut Mize) -> MizeResult<()> {
let (date_str, save_for_yesterday) = get_target_date();
// Fetch dailies and completed todos from Habitica
let (dailies, completed_todos) = {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
let dailies = hab.get_tasks("dailys")?;
let completed_todos = hab.get_tasks("completedTodos")?;
(dailies, completed_todos)
};
let c2vi = mize.get_part_native::<C2vi>("c2vi")?;
c2vi.with_db(|conn| {
let mut data = get_or_create_log(conn, &date_str)?;
// Process dailies
if let Some(arr) = dailies.as_array() {
for daily in arr {
let is_due = if save_for_yesterday {
// Look at history for yesterday's status
daily["history"]
.as_array()
.and_then(|h| h.iter().max_by_key(|e| e["date"].as_i64().unwrap_or(0)))
.map(|e| e["isDue"].as_bool().unwrap_or(false))
.unwrap_or(false)
} else {
daily["isDue"].as_bool().unwrap_or(false)
};
if !is_due {
continue;
}
let completed = if save_for_yesterday {
daily["history"]
.as_array()
.and_then(|h| h.iter().max_by_key(|e| e["date"].as_i64().unwrap_or(0)))
.map(|e| e["completed"].as_bool().unwrap_or(false))
.unwrap_or(false)
} else {
daily["completed"].as_bool().unwrap_or(false)
};
let daily_id = daily["id"].as_str().unwrap_or("");
let daily_text = daily["text"].as_str().unwrap_or("");
if completed {
data["dailies_done"]
.as_array_mut()
.unwrap()
.push(json!(daily_id));
println!("daily-done: {}", daily_text);
} else {
data["dailies_skipped"]
.as_array_mut()
.unwrap()
.push(json!(daily_id));
println!("daily-skipped: {}", daily_text);
}
}
}
// Process completed todos
if let Some(arr) = completed_todos.as_array() {
let today_str = format_date(Utc::now());
for todo in arr {
let date_completed_str = todo["dateCompleted"].as_str().unwrap_or("");
// Parse the completion date (ISO 8601 format from Habitica)
let date_completed = chrono::DateTime::parse_from_rfc3339(date_completed_str)
.map(|d| format_date(d.with_timezone(&Utc)))
.unwrap_or_default();
// Check if already in the log for this date
if save_for_yesterday {
let existing_todos = data["todos_done"].as_array().unwrap();
let todo_id = todo["id"].as_str().unwrap_or("");
if existing_todos
.iter()
.any(|t| t["id"].as_str() == Some(todo_id))
{
continue;
}
}
let todo_entry = json!({
"id": todo["id"],
"text": todo["text"],
"notes": todo.get("notes").unwrap_or(&json!("")),
"checklist": todo.get("checklist").unwrap_or(&json!([])),
"tags": todo.get("tags").unwrap_or(&json!([])),
});
// Save if completed on the target date
if date_completed == date_str {
println!("todo-done: {}", todo["text"].as_str().unwrap_or(""));
data["todos_done"]
.as_array_mut()
.unwrap()
.push(todo_entry.clone());
}
// If saving for yesterday but todo was completed today, also record it
if save_for_yesterday && date_completed == today_str {
println!("todo-done: {}", todo["text"].as_str().unwrap_or(""));
data["todos_done"].as_array_mut().unwrap().push(todo_entry);
}
}
}
upsert_log(conn, &date_str, &data)?;
Ok(())
})
}
fn skip_habitica_todo(mize: &mut Mize, num: usize) -> MizeResult<()> {
let (date_str, _) = get_target_date();
// Fetch todos from Habitica
let todos = {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
hab.get_tasks("todos")?
};
let arr = todos
.as_array()
.ok_or_else(|| mize_err!("Expected array of todos"))?;
// Delete and log each skipped todo
for i in 0..num {
let todo = match arr.get(i) {
Some(t) => t,
None => continue,
};
let todo_id = todo["id"].as_str().unwrap_or("");
// Delete the todo and score the skip habit
{
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
hab.delete_task(todo_id)?;
}
increment_habit(mize)?;
// Record in log
let c2vi = mize.get_part_native::<C2vi>("c2vi")?;
c2vi.with_db(|conn| {
let mut data = get_or_create_log(conn, &date_str)?;
data["todos_skipped"].as_array_mut().unwrap().push(json!({
"id": todo["id"],
"text": todo["text"],
"notes": todo.get("notes").unwrap_or(&json!("")),
"checklist": todo.get("checklist").unwrap_or(&json!([])),
"tags": todo.get("tags").unwrap_or(&json!([])),
}));
upsert_log(conn, &date_str, &data)?;
Ok(())
})?;
}
Ok(())
}
fn increment_habit(mize: &mut Mize) -> MizeResult<()> {
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
let habits = hab.get_tasks("habits")?;
if let Some(arr) = habits.as_array() {
for habit in arr {
if habit["text"].as_str() == Some("skipped a planned task") {
let id = habit["id"].as_str().unwrap_or("");
hab.api_request(
reqwest::Method::POST,
format!("tasks/{}/score/down", id),
json!({}),
)?;
return Ok(());
}
}
}
Ok(())
}
fn add_action_items(mize: &mut Mize) -> MizeResult<()> {
let initial_content = "\
gu 10min TIMER
uw 15min FROG
b coffee/tee/kakau
uw FROG
b breakfast, brush teeth, lüften
uw FROG
lunch
uw 15min TIMER of next days FROG
fw
dt 30min TIMER
fun 20:00 (smth that can be stopped easily)
se 21:40
read
bed 22:10";
let tmp_file =
std::env::temp_dir().join(format!("habitica-tasks-{}.md", Utc::now().timestamp()));
std::fs::write(&tmp_file, initial_content)
.map_err(|e| mize_err!("Failed to write temp file: {}", e))?;
// Open in nvim for editing
std::process::Command::new("nvim")
.arg(&tmp_file)
.status()
.map_err(|e| mize_err!("Failed to run nvim: {}", e))?;
// Read back and parse
let list_str = std::fs::read_to_string(&tmp_file)
.map_err(|e| mize_err!("Failed to read temp file: {}", e))?;
let _ = std::fs::remove_file(&tmp_file);
let tasks: Vec<&str> = list_str
.lines()
.map(|l| l.trim())
.filter(|l| !l.is_empty())
.collect();
// Add tasks to Habitica
let mut task_ids = Vec::new();
{
let mut hab = mize.get_part_native::<Habitica>("habitica")?;
for todo_text in &tasks {
let data = hab.api_request(
reqwest::Method::POST,
"tasks/user".to_string(),
json!({ "text": todo_text, "type": "todo" }),
)?;
let id = data["id"].as_str().unwrap_or("").to_string();
println!("Added to-do: '{}' with id '{}'", todo_text, id);
task_ids.push(id);
}
// Move all tasks to bottom
for task_id in &task_ids {
hab.api_request(
reqwest::Method::POST,
format!("tasks/{}/move/to/-1", task_id),
json!({}),
)?;
}
}
Ok(())
}
fn dump_habitica_logs(mize: &mut Mize) -> MizeResult<()> {
let c2vi = mize.get_part_native::<C2vi>("c2vi")?;
c2vi.with_db(|conn| {
let mut stmt = conn
.prepare("SELECT date, data FROM habitica_log ORDER BY date")
.map_err(|e| mize_err!("DB error: {}", e))?;
let rows = stmt
.query_map([], |row| {
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
})
.map_err(|e| mize_err!("DB error: {}", e))?;
for row in rows {
let (date, data_str) = row.map_err(|e| mize_err!("DB error: {}", e))?;
let data: Value =
serde_json::from_str(&data_str).unwrap_or(json!({"parse_error": data_str}));
println!(
"{}: {}",
date,
serde_json::to_string_pretty(&data).unwrap_or_default()
);
}
Ok(())
})
}

277
marts/src/cli/mod.rs Normal file
View File

@@ -0,0 +1,277 @@
use clap::{ArgMatches, Command};
use dioxus::core::SpawnIfAsync;
use std::{cell::RefCell, collections::HashMap};
use mize::{
item::ItemData, mize_err, mize_part, proto::MizeMessage, Mize, MizeError, MizePart,
MizePartCreate, MizeResult,
};
thread_local! {
pub static MIZE_CLI_SPAWN: RefCell<Option<CliSpawn>> = const { RefCell::new(None) };
}
struct CliSpawn {
wait_rx: flume::Receiver<()>,
conn_id: Option<u64>,
mize: Mize,
}
pub fn cli_spawn_setup(mize: Mize, conn_id: u64, wait_rx: flume::Receiver<()>) -> MizeResult<()> {
MIZE_CLI_SPAWN.with(|cell| {
*cell.borrow_mut() = Some(CliSpawn {
wait_rx,
conn_id: Some(conn_id),
mize,
});
Ok(())
})
}
pub fn cli_spawn(cmd: String) -> MizeResult<()> {
MIZE_CLI_SPAWN.with(|cell| {
if let Some(spawn) = cell.borrow().as_ref() {
let conn_id = spawn.conn_id.ok_or(mize_err!("no conn_id set"))?;
let conn = spawn.mize.get_connection(conn_id)?;
conn.send(MizeMessage::new_custom("cli.spawn", cmd, conn_id))?;
log::info!("before wait_rx.recv()");
spawn.wait_rx.recv()?;
log::info!("after wait_rx.recv()");
Ok(())
} else {
Err(mize_err!("no cli spawn thread_local available"))
}
})
}
#[mize_part("cli")]
#[derive(Default)]
pub struct CliPart {
mize: Mize,
cmds: HashMap<String, Command>,
current_cmd_name: String,
actions:
HashMap<String, Box<dyn Fn(&ArgMatches, Mize) -> MizeResult<()> + Send + Sync + 'static>>,
parsers: Option<Vec<Box<dyn Fn(Mize, Vec<String>) -> MizeResult<()> + Send + Sync + 'static>>>,
}
pub fn cli(mize: &mut Mize) -> MizeResult<()> {
let command = Command::new("marts-cli").allow_external_subcommands(true);
let (spawn_wait_tx, spawn_wait_rx) = flume::unbounded::<()>();
let mut cli_part = CliPart {
mize: mize.clone(),
cmds: HashMap::new(),
current_cmd_name: "marts-cli".to_string(),
actions: HashMap::new(),
parsers: Some(Vec::new()),
};
cli_part.cmds.insert("marts-cli".to_string(), command);
mize.add_listener(
"cli",
Box::new(move |msg, mize| cli_msg_handler(msg, mize, spawn_wait_rx.clone())),
)?;
mize.add_listener(
"cli.spawn_done",
Box::new(move |mut msg, mize| {
log::info!("before send");
spawn_wait_tx.send(());
log::info!("after send");
Ok(())
}),
)?;
mize.add_part(Box::new(cli_part))
}
impl MizePartCreate for CliPart {}
impl MizePart for CliPart {
fn init(&mut self, _mize: &mut Mize) -> MizeResult<()> {
Ok(())
}
fn run(&mut self, mize: &mut Mize) -> MizeResult<()> {
if mize
.get_config("cli.daemonMode")
.unwrap_or(ItemData::new())
.as_bool()
.unwrap_or(false)
{
return Ok(());
}
let matches = self
.cmds
.remove(&self.current_cmd_name)
.unwrap()
.get_matches();
let sub_cmd = match matches.subcommand_name() {
Some(sub_cmd) => sub_cmd,
None => {
log::info!("No subcommand provided");
return Ok(());
}
};
let action = match self.actions.remove(sub_cmd) {
Some(action) => action,
None => {
// external parsers
for parser in self.parsers.take().unwrap() {
parser(
mize.clone(),
matches
.subcommand_matches(sub_cmd)
.unwrap()
.get_many::<String>("")
.unwrap()
.map(|s| s.to_string())
.collect::<Vec<String>>(),
)?;
}
return Ok(());
}
};
action(matches.subcommand_matches(sub_cmd).unwrap(), mize.clone())?;
Ok(())
}
fn opts(&self, mize: &mut Mize) {
mize.new_opt("cli.name");
}
}
fn cli_msg_handler(
mut msg: MizeMessage,
mize: &mut Mize,
spawn_wait_rx: flume::Receiver<()>,
) -> MizeResult<()> {
let binding = msg.data()?;
let data = binding
.cbor()
.as_array()
.ok_or(mize_err!("cli message data was not an array"))?;
let full_cmd = data
.iter()
.nth(0)
.cloned()
.ok_or(mize_err!("cli message data was empty"))?;
let full_cmd_text = full_cmd
.as_text()
.ok_or(mize_err!("cli cmd was not text"))?;
let cmd = full_cmd_text
.split("/")
.last()
.ok_or(mize_err!("failed to get cmd"))?;
let mut cli = mize.get_part_native::<CliPart>("cli").unwrap();
let command = cli
.cmds
.get(cmd)
.ok_or(mize_err!("no command {cmd} in cli part"))?;
let matches = command
.clone()
.get_matches_from(data.iter().map(|v| v.as_text().unwrap()));
let sub_cmd_name = match matches.subcommand_name() {
Some(sub_cmd) => sub_cmd,
None => {
cli.send_stderr(&msg, "No subcommand provided".to_string())?;
return Ok(());
}
};
let action = match cli.actions.get_mut(sub_cmd_name) {
Some(action) => action,
None => {
return Ok(());
}
};
let original_msg = msg.clone();
let mize_clone = mize.clone();
mize::MIZE_STDOUT.with(move |stdout| {
*stdout.borrow_mut() = Some(Box::new(move |string| {
let conn_id = original_msg.conn_id;
let msg = MizeMessage::new_custom("cli.stdout", string, conn_id);
let conn = mize_clone.get_connection(conn_id)?;
conn.send(msg)?;
Ok(())
}))
});
cli_spawn_setup(mize.clone(), msg.conn_id, spawn_wait_rx)?;
action(
matches.subcommand_matches(sub_cmd_name).unwrap(),
mize.clone(),
)?;
cli.send_done(&msg)?;
Ok(())
}
impl CliPart {
pub fn set_command(&mut self, cmd: &str) {
self.current_cmd_name = cmd.to_owned();
if !self.cmds.contains_key(cmd) {
self.cmds
.insert(cmd.to_owned(), Command::new(cmd.to_owned()));
}
}
pub fn send_stderr(&mut self, original_msg: &MizeMessage, msg: String) -> MizeResult<()> {
let conn_id = original_msg.conn_id;
let msg = MizeMessage::new_custom("cli.stderr", msg, conn_id);
let conn = self.mize.get_connection(conn_id)?;
conn.send(msg)?;
Ok(())
}
pub fn send_stdout(&mut self, original_msg: &MizeMessage, msg: String) -> MizeResult<()> {
let conn_id = original_msg.conn_id;
let msg = MizeMessage::new_custom("cli.stdout", msg, conn_id);
let conn = self.mize.get_connection(conn_id)?;
conn.send(msg)?;
Ok(())
}
pub fn send_spawn(&mut self, original_msg: &MizeMessage, msg: String) -> MizeResult<()> {
let conn_id = original_msg.conn_id;
let msg = MizeMessage::new_custom("cli.spawn", msg, conn_id);
let conn = self.mize.get_connection(conn_id)?;
conn.send(msg)?;
Ok(())
}
pub fn send_done(&mut self, original_msg: &MizeMessage) -> MizeResult<()> {
let conn_id = original_msg.conn_id;
let msg = MizeMessage::new_custom("cli.done", ItemData::new(), conn_id);
let conn = self.mize.get_connection(conn_id)?;
conn.send(msg)?;
Ok(())
}
pub fn subcommand<T: Fn(&ArgMatches, Mize) -> MizeResult<()> + Send + Sync + 'static>(
&mut self,
subcmd: Command,
action: T,
) -> MizeResult<()> {
let name = subcmd.get_name().to_string();
let mut cmd = self
.cmds
.remove(&self.current_cmd_name)
.unwrap()
.subcommand(subcmd);
self.cmds.insert(self.current_cmd_name.clone(), cmd);
self.actions.insert(name, Box::new(action));
Ok(())
}
pub fn add_sub_parser<T: Fn(Mize, Vec<String>) -> MizeResult<()> + Send + Sync + 'static>(
&mut self,
parser: T,
) -> MizeResult<()> {
self.parsers.as_mut().unwrap().push(Box::new(parser));
Ok(())
}
pub fn with_cmd(
&mut self,
func: fn(mize: Mize, cmd: Command) -> MizeResult<Command>,
) -> MizeResult<()> {
let cmd = self.cmds.remove(&self.current_cmd_name).ok_or(mize_err!(
"current command {} not found",
self.current_cmd_name
))?;
let cmd = func(self.mize.clone(), cmd)?;
self.cmds.insert(self.current_cmd_name.clone(), cmd);
Ok(())
}
}

128
marts/src/habitica.rs Normal file
View File

@@ -0,0 +1,128 @@
use chrono::{DateTime, Utc};
use mize::{mize_err, mize_part, Mize, MizeError, MizePart, MizePartCreate, MizeResult};
use reqwest::{blocking::Client, blocking::Response, header, Method};
use serde_json::{json, Value};
use std::thread::sleep;
use std::time::Duration;
#[mize_part]
#[derive(Default)]
pub struct Habitica {
mize: Mize,
client: Client,
}
pub fn habitica(mize: &mut Mize) -> MizeResult<()> {
let client = Client::new();
mize.register_part(Box::new(Habitica {
mize: mize.clone(),
client,
}))
}
impl MizePartCreate for Habitica {}
impl MizePart for Habitica {
fn opts(&self, mize: &mut Mize) {
mize.new_opt("habitica.api_url");
mize.new_opt("habitica.user_id");
mize.new_opt("habitica.api_token");
mize.new_opt("habitica.client_name");
}
}
impl Habitica {
pub fn api_request(&mut self, method: Method, path: String, data: Value) -> MizeResult<Value> {
let api_url = self.mize.get_config("habitica.api_url")?.to_string();
let user_id = self.mize.get_config("habitica.user_id")?.to_string();
let api_token = self.mize.get_config("habitica.api_token")?.to_string();
let client_name = self.mize.get_config("habitica.client_name")?.to_string();
let mut headers = header::HeaderMap::new();
headers.insert(
"Content-Type",
header::HeaderValue::from_static("application/json"),
);
headers.insert("x-api-user", header::HeaderValue::from_str(&user_id)?);
headers.insert("x-api-key", header::HeaderValue::from_str(&api_token)?);
headers.insert("x-client", header::HeaderValue::from_str(&client_name)?);
let url = format!("{}/{}", api_url, path.trim_start_matches('/'));
let mut request_builder = self.client.request(method, &url).headers(headers);
if data != json!({}) {
request_builder = request_builder.json(&data);
}
let response = request_builder.send()?;
handle_rate_limit(&response);
if !response.status().is_success() {
let status = response.status();
let text = response.text().unwrap_or_default();
log::info!("request_url: {}", url);
return Err(mize_err!(
"Habitica API error: {} {} - {}",
status.as_u16(),
status.canonical_reason().unwrap_or(""),
text
));
}
let json_response: Value = response.json()?;
Ok(json_response.get("data").cloned().unwrap_or(Value::Null))
}
pub fn get_tasks(&mut self, task_type: &str) -> MizeResult<Value> {
self.api_request(
Method::GET,
format!("tasks/user?type={}", task_type),
json!({}),
)
}
pub fn delete_task(&mut self, id: &str) -> MizeResult<Value> {
self.api_request(Method::DELETE, format!("tasks/{}", id), json!({}))
}
}
fn handle_rate_limit(response: &Response) {
let headers = response.headers();
let limit = headers
.get("X-RateLimit-Limit")
.and_then(|v| v.to_str().ok())
.unwrap_or("NONE");
let remaining = headers
.get("X-RateLimit-Remaining")
.and_then(|v| v.to_str().ok())
.and_then(|s| s.parse::<i32>().ok())
.unwrap_or(10);
let reset = headers
.get("X-RateLimit-Reset")
.and_then(|v| v.to_str().ok());
log::info!(
"RateLimit: {} | Remaining: {} | Reset: {}",
limit,
remaining,
reset.unwrap_or("N/A")
);
if remaining < 2 {
if let Some(reset_str) = reset {
if let Ok(reset_date) = DateTime::parse_from_rfc2822(reset_str) {
let now = Utc::now();
let wait_duration = reset_date.signed_duration_since(now).to_std();
if let Ok(wait_duration) = wait_duration {
let wait_ms = wait_duration.as_millis() as u64 + 1000;
log::info!(
"Waiting {} secs for next rate limit window...",
(wait_ms / 1000)
);
sleep(Duration::from_millis(wait_ms));
}
}
}
}
}

4
marts/src/index.ts Normal file
View File

@@ -0,0 +1,4 @@
export * from "./task/obsidian-otask-parse";
export * from "./c2vi/obsidian-canvas-patch";
export * from "./js/stub_deno";

53
marts/src/js/glue_deno.rs Normal file
View File

@@ -0,0 +1,53 @@
use deno_core::extension;
use deno_core::op2;
use deno_core::OpState;
use mize::{Mize, MizeError};
use std::sync::Arc;
use crate::CliPart;
use crate::JsPart;
#[op2(fast)]
fn op_mize_get_part(#[string] name: &str) {
log::info!("js wants the part {}", name);
}
#[op2]
#[string]
fn op_mize_get_config(state: &mut OpState, #[string] key: &str) -> String {
let mize: Mize = state.borrow::<Mize>().clone();
format!("config_value_for_{}", key)
}
#[op2(fast)]
fn op_mize_add_part(state: &mut OpState, #[string] key: &str) {
let mize: Mize = state.borrow::<Mize>().clone();
log::info!("adding part {}", key);
}
#[op2(fast)]
fn op_cli_subcommand(state: &mut OpState, #[string] name: String) {
let mut mize: Mize = state.borrow::<Mize>().clone();
let mut cli = mize.get_part_native::<CliPart>("cli").unwrap();
cli.subcommand(
clap::Command::new(name.clone()),
move |_sub_matches, mut mize| {
let mut js = mize.get_part_native::<JsPart>("js").unwrap();
js.eval("log(\"cli command being called\")".to_string())?;
js.eval(format!("mize.vars.cli.subcommand({name})"))?;
Ok(())
},
);
}
// Create an extension with your custom ops
extension!(
my_extension,
ops = [op_mize_get_part, op_mize_get_config],
options = {
mize: Mize,
},
state = |state: &mut OpState, config: Config| {
state.put::<Mize>(config.mize);
},
);

11
marts/src/js/glue_deno.ts Normal file
View File

@@ -0,0 +1,11 @@
import { Mize } from "./stub_deno.ts";
function argsToMessage(...args) {
return args.map((arg) => JSON.stringify(arg)).join(" ");
}
globalThis.log = (...args) => {
Deno.core.print(`[out]: ${argsToMessage(...args)}\n`, false);
};
globalThis.mize = new Mize();

136
marts/src/js/mod.rs Normal file
View File

@@ -0,0 +1,136 @@
use deno_core::error::AnyError;
use deno_core::url::Url;
use deno_core::v8::OneByteConst;
use deno_core::{
ascii_str_include, extension, op2, JsRuntime, ModuleSpecifier, PollEventLoopOptions,
RuntimeOptions,
};
use deno_core::{FastStaticString, FsModuleLoader};
use mize::async_trait;
use mize::instance::MizePartCreate;
use mize::{mize_part, Mize, MizePart, MizeResult};
use std::future::Future;
use std::path::Path;
use std::pin::Pin;
use std::rc::Rc;
use std::str::FromStr;
use tokio::runtime::Builder;
use tokio::task::{spawn_local, LocalSet};
mod glue_deno;
#[mize_part("js")]
#[derive(Default)]
pub struct JsPart {
mize: Mize,
sender: Option<flume::Sender<JsRuntimeThreadMessage>>,
}
pub type BoxFuture<'a> = Pin<Box<dyn Future<Output = MizeResult<()>> + 'a>>;
pub type BoxClosure = Box<dyn for<'a> FnOnce(&'a mut JsRuntime) -> BoxFuture<'a> + Send + 'static>;
impl MizePart for JsPart {
fn run(&mut self, mize: &mut Mize) -> MizeResult<()> {
log::info!("js part run");
self.sender
.as_ref()
.unwrap()
.send(JsRuntimeThreadMessage::DoRunPhase)
.unwrap();
Ok(())
}
}
pub fn js(mize: &mut Mize) -> MizeResult<()> {
let (sender, receiver) = flume::unbounded::<JsRuntimeThreadMessage>();
let mize_clone = mize.clone();
// the thread which will run any js
mize.spawn_and_wait("js_runtime_thread", || {
js_runtime_thread(mize_clone, receiver)
})?;
mize.add_part(Box::new(JsPart {
mize: mize.clone(),
sender: Some(sender),
}))
}
pub fn part_from_file(
mize: &mut Mize,
name: &'static str,
code: FastStaticString,
) -> MizeResult<()> {
let mut js = mize.get_part_native::<JsPart>("js")?;
js.execute_init_js(code)?;
Ok(())
}
impl JsPart {
pub fn execute_init_js(&mut self, code: FastStaticString) -> MizeResult<()> {
self.sender
.as_ref()
.unwrap()
.send(JsRuntimeThreadMessage::RunInitJs(code))
.unwrap();
Ok(())
}
pub fn eval(&mut self, code: String) -> MizeResult<()> {
self.sender
.as_ref()
.unwrap()
.send(JsRuntimeThreadMessage::RunJs(code))
.unwrap();
Ok(())
}
}
fn js_runtime_thread(
mize: Mize,
receiver: flume::Receiver<JsRuntimeThreadMessage>,
) -> MizeResult<()> {
let mut js_runtime = JsRuntime::new(RuntimeOptions {
//module_loader: Some(Rc::new(FsModuleLoader)),
//extensions: vec![glue_deno::my_extension::init(mize.clone())],
..Default::default()
});
js_runtime
.execute_script("[stub]", ascii_str_include!("../deno_dist/glue_deno.js"))
.unwrap();
loop {
log::info!("js thread waiting for smth");
let msg = receiver.recv().unwrap();
log::info!("js thread got smth");
match msg {
JsRuntimeThreadMessage::RunInitJs(js_code) => {
if let Err(err) = js_runtime.execute_script("[init]", js_code) {
log::info!("err: {}", err);
}
log::info!("done running js");
}
JsRuntimeThreadMessage::RunJs(js_code) => {
if let Err(err) = js_runtime.execute_script("[idk]", js_code) {
log::info!("err: {}", err);
}
log::info!("done running js");
}
JsRuntimeThreadMessage::DoRunPhase => {
if let Err(err) = js_runtime.execute_script("[runPhase]", "mize.runPhase()") {
log::info!("err: {}", err);
}
log::info!("done with runPhase");
return Ok(());
}
}
}
}
enum JsRuntimeThreadMessage {
//Closure(Box<dyn FnOnce(&mut JsRuntime) -> MizeResult<()> + Send>),
//AsyncClosure(BoxClosure),
RunInitJs(FastStaticString),
RunJs(String),
DoRunPhase,
}

18
marts/src/js/stub_deno.ts Normal file
View File

@@ -0,0 +1,18 @@
export class Mize {
async get_config(key: string): Promise<any> {
return "hiiiiiii";
}
async add_opts(opts: any): Promise<void> {}
async add_part(part: any): Promise<void> {}
async report_err(err: MizeError) {}
runPhase() {
Deno.core.print("runPhase in js \n");
}
}
type MizeError = string;
export class Cli {
constructor(mize: Mize) {}
async sub_command(name: string, cmd: any): Promise<void> {}
}

44
marts/src/lib.rs Normal file
View File

@@ -0,0 +1,44 @@
#![feature(oneshot_channel)]
#[cfg(feature = "target-os")]
pub mod cli;
#[cfg(feature = "target-os")]
pub use cli::*;
#[cfg(feature = "target-os")]
pub mod habitica;
#[cfg(feature = "target-os")]
pub use habitica::*;
#[cfg(feature = "target-os")]
pub mod c2vi {
pub mod part;
pub mod dashboard;
}
#[cfg(feature = "target-os")]
pub use c2vi::part::*;
#[cfg(feature = "target-obsidian")]
pub mod obsidian;
#[cfg(feature = "target-obsidian")]
pub use obsidian::*;
pub mod spacetime;
pub use spacetime::*;
pub mod auth {
pub mod client;
pub use client::*;
}
pub use auth::*;
pub mod ppc;
pub mod timetrack;
//#[cfg(feature = "target-os")]
//pub mod js;
//#[cfg(feature = "target-os")]
//pub use js::*;
pub use generated;

88
marts/src/obsidian.rs Normal file
View File

@@ -0,0 +1,88 @@
use mize::{Mize, MizeResult, MizePart, MizePartCreate, mize_part};
use wasm_bindgen::JsValue;
use mize::error::IntoMizeResultJsValue;
use wasm_bindgen::prelude::*;
// console_log macro
#[wasm_bindgen]
extern "C" {
#[wasm_bindgen(js_namespace = console)]
fn log(s: &str);
}
macro_rules! console_log {
// Note that this is using the `log` function imported above during
// `bare_bones`
($($t:tt)*) => ({ log(&format_args!($($t)*).to_string())})
}
pub fn part_obsidian(mize: &mut Mize, plugin: JsValue, app: JsValue) -> MizeResult<()> {
mize.register_part(Box::new(ObsidianPart {
mize: mize.clone(),
plugin,
app,
}));
Ok(())
}
#[mize_part("obsidian")]
#[derive(Default)]
pub struct ObsidianPart {
mize: Mize,
plugin: JsValue, // the jsValue of the obsidian plugin
app: JsValue, // the jsValue of the obsidian instance
}
pub struct MartsCommand {
pub id: String,
pub name: String,
pub callback: Box<dyn FnMut(Mize) -> MizeResult<()>>,
}
impl MizePart for ObsidianPart {}
impl MizePartCreate for ObsidianPart {}
impl ObsidianPart {
pub fn plugin(&self) -> &JsValue {
&self.plugin
}
pub fn add_command(&self, mut cmd: MartsCommand) -> MizeResult<()> {
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
use js_sys::{Function, Reflect, Object};
let obsidian_cmd = Object::new();
Reflect::set(&obsidian_cmd, &"id".into(), &"login".into()).mres()?;
Reflect::set(&obsidian_cmd, &"name".into(), &"Login".into()).mres()?;
// callback: () => run_login()
let mize_clone = self.mize.clone();
let cb = Closure::<dyn FnMut()>::wrap(Box::new(move || {
if let Err(e) = (cmd.callback)(mize_clone.clone()) {
mize_clone.report_err(e);
}
}));
Reflect::set(&obsidian_cmd, &"callback".into(), cb.as_ref()).mres()?;
// plugin.addCommand(cmd)
let add_command_fn = Reflect::get(&self.plugin, &"addCommand".into()).mres()?
.dyn_into::<Function>().mres()?;
add_command_fn
.call1(&self.plugin, &obsidian_cmd).mres()?;
// IMPORTANT: prevent callback from being dropped
cb.forget();
Ok(())
}
}

183
marts/src/ppc/main_page.rs Normal file
View File

@@ -0,0 +1,183 @@
use dioxus::prelude::*;
pub fn main_page() -> Element {
rsx! {
"hello world"
div {
class: "min-h-screen bg-gray-100 items-center flex h-screen items-center justify-center",
// Header
header {
class: "bg-blue-600 text-white py-4",
div {
class: "container mx-auto px-4",
h1 {
class: "text-2xl font-bold",
"People Corner (PPC)"
}
}
}
// Main content
main {
class: "container mx-auto px-4 py-8",
// Hero section
section {
class: "bg-white rounded-lg shadow-md p-6 mb-8",
h2 {
class: "text-xl font-semibold mb-4 text-blue-600",
"Welcome to People Corner"
}
p {
class: "text-gray-700 mb-4",
"PPC is a social platform for intentional people who want to get more out of life together."
}
p {
class: "text-gray-700",
"We're building a community-driven social media network that brings people together to do cool things, share resources, and support each other."
}
}
// Mission section
section {
class: "bg-white rounded-lg shadow-md p-6 mb-8",
h2 {
class: "text-xl font-semibold mb-4 text-blue-600",
"Our Mission"
}
ul {
class: "list-disc pl-6 space-y-2",
li { "Bring people together to get more out of life" }
li { "Create meaningful connections beyond superficial social media" }
li { "Support intentional living and personal growth" }
li { "Build a platform by people, for people" }
li { "Foster real-world meetups and collaborations" }
li { "Create a social media for diligent, intentional people" }
}
}
// Key concepts section
section {
class: "bg-white rounded-lg shadow-md p-6",
h2 {
class: "text-xl font-semibold mb-4 text-blue-600",
"Key Concepts"
}
ul {
class: "list-disc pl-6 space-y-2",
li {
strong { "Trust Network: " }
"A system for building and maintaining trust between members"
}
li {
strong { "Intentional Communities: " }
"Both online and physical spaces for like-minded people"
}
li {
strong { "Resource Sharing: " }
"Platform for sharing skills, knowledge, and physical resources"
}
li {
strong { "Real Connections: " }
"Focus on meaningful relationships over superficial interactions"
}
li {
strong { "Personal Growth: " }
"Support for individuals to achieve their life goals"
}
}
}
}
// Footer
footer {
class: "bg-blue-600 text-white py-4 mt-8",
div {
class: "container mx-auto px-4 text-center",
p { "© 2024 People Corner. All rights reserved." }
}
}
}
}
}
/// About page component
pub fn about_page() -> Element {
rsx! {
div {
class: "container mx-auto px-4 py-8",
h1 {
class: "text-2xl font-bold mb-4 text-blue-600",
"About PPC"
}
p {
class: "text-gray-700 mb-4",
"People Corner (PPC) is more than just a social platform - it's a movement to bring intentional people together to create meaningful connections and achieve more in life."
}
p {
class: "text-gray-700",
"Our vision is to build a world where people support each other, share resources, and grow together through real-world interactions and digital connections."
}
}
}
}
/// Contact page component
pub fn contact_page() -> Element {
rsx! {
div {
class: "container mx-auto px-4 py-8",
h1 {
class: "text-2xl font-bold mb-4 text-blue-600",
"Contact Us"
}
p {
class: "text-gray-700 mb-4",
"We'd love to hear from you! Whether you have questions, feedback, or want to get involved, please reach out."
}
form {
class: "max-w-md space-y-4",
div {
label {
class: "block text-gray-700 mb-2",
"Name"
}
input {
class: "w-full px-3 py-2 border rounded-lg",
r#type: "text",
placeholder: "Your name"
}
}
div {
label {
class: "block text-gray-700 mb-2",
"Email"
}
input {
class: "w-full px-3 py-2 border rounded-lg",
r#type: "email",
placeholder: "Your email"
}
}
div {
label {
class: "block text-gray-700 mb-2",
"Message"
}
textarea {
class: "w-full px-3 py-2 border rounded-lg h-32",
placeholder: "Your message"
}
}
button {
class: "bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700",
r#type: "submit",
"Send Message"
}
}
}
}
}

4
marts/src/ppc/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
pub mod main_page;
pub use main_page::main_page;

107
marts/src/spacetime.rs Normal file
View File

@@ -0,0 +1,107 @@
use std::sync::oneshot::Sender;
use mize::{mize_part, Mize, MizePart, MizePartCreate, MizeResult};
use crate::auth::client::ClientAuth;
use generated::spacetime::DbConnection;
#[mize_part]
#[derive(Default)]
pub struct SpacetimePart {
mize: Mize,
con: Option<DbConnection>,
}
impl MizePartCreate for SpacetimePart {}
impl MizePart for SpacetimePart {
fn name(&self) -> &'static str {
"spacetime"
}
}
impl SpacetimePart {
pub fn con_mut(&mut self) -> &mut DbConnection {
self.con.as_mut().unwrap()
}
pub fn con(&self) -> &DbConnection {
self.con.as_ref().unwrap()
}
// does not work, because i can't use the QueryBuilder type in my code.... it's private
/*
pub fn get<T>(
&self,
on_applied: impl FnOnce(&generated::spacetime::SubscriptionEventContext, Sender<T>)+ Send + 'static,
query: impl FnOnce(QueryBuilder)+ Send + 'static
) -> MizeResult<T> {
let (done_tx, done_rx) = std::sync::oneshot::channel::<T>();
use spacetimedb_sdk::DbContext;
self.con().subscription_builder().on_applied(move |ctx| {
on_applied(ctx, done_tx);
}).add_query(build);
}
*/
}
pub fn spacetime(mize: &mut Mize) -> MizeResult<()> {
let auth = mize.get_part_native::<ClientAuth>("client_auth")?;
let db_url = mize.get_config("spacetime.url")?.value_string()?;
let con: Option<DbConnection>;
#[cfg(feature = "target-os")] {
con = Some(DbConnection::builder()
.with_token(Some(auth.id_token()?))
.with_database_name("mize")
.with_uri(db_url)
//.on_connect(callback)
//.on_disconnect(callback)
//.on_connect_error(callback)
//.with_compression(...)
.build()?);
con.as_ref().unwrap().run_threaded();
}
#[cfg(feature = "target-js")] {
let mut mize = mize.clone();
con = None;
wasm_bindgen_futures::spawn_local(async move {
let id_token = match auth.id_token() {
Ok(val) => val,
Err(e) => {
mize.report_err(e.into());
return;
}
};
let con = match DbConnection::builder()
.with_token(Some(id_token))
.with_database_name("mize")
.with_uri(db_url)
//.on_connect(callback)
//.on_disconnect(callback)
//.on_connect_error(callback)
//.with_compression(...)
.build().await {
Ok(val) => val,
Err(e) => {
mize.report_err(e.into());
return;
}
};
let mut spacetime = match mize.get_part_native::<SpacetimePart>("spacetime") {
Ok(val) => val,
Err(e) => {
mize.report_err(e.into());
return;
}
};
con.run_async();
spacetime.con = Some(con);
});
}
mize.register_part(Box::new(SpacetimePart {
mize: mize.clone(),
con: con,
}))
}

0
marts/src/task/mod.rs Normal file
View File

View File

@@ -0,0 +1,538 @@
import remarkParse from "remark-parse";
import remarkStringify from "remark-stringify";
import { unified } from "unified";
import { visit } from "unist-util-visit";
import { Root, Content, Heading, List, ListItem, Text } from "mdast";
import remarkFrontmatter from "remark-frontmatter";
import remarkGfm from "remark-gfm";
import wikiLinkPlugin from "@flowershow/remark-wiki-link";
import { TFile } from "obsidian";
const OTASK_PROP_NAMES = ["short", "outcome", "priority"];
const ACTION_PROP_NAMES = [];
const REMARK = unified()
.use(remarkParse)
.use(remarkFrontmatter)
.use(remarkGfm)
.use(remarkStringify, { bullet: "-" });
export function task_obsidian_otask_parse(mize: any) {
const obsidian_plugin = mize.get_part_native("obsidian");
obsidian_plugin.addCommand({
id: "full-parse",
name: "Parse all otask files into the db",
callback: async () => {
await full_parse();
},
});
}
async function full_parse() {
console.log("full parse.......................... yay");
let otasks = [];
OTASK_PARSE_QUEUE = [];
OTASK_LIST = [];
const root_file = app.vault.getAbstractFileByPath("task/my-projects.md");
const content = await app.vault.read(root_file);
const list = await getListUnderHeading(content, "root");
for (const entry of list) {
queue_otasks_from_list_item(entry, []);
}
// go through the queue of otaskParseTasks
// using a queue instead of recursive calls, to make it more debuggable...
do {
const task = OTASK_PARSE_QUEUE.shift();
dbg("task", task);
if (task.file) {
const otask = await otasks_from_file(
task.file,
task.otask_path,
task.priority,
);
dbg("otask", otask);
if (otask) {
OTASK_LIST.push(otask);
}
} else {
const otask = await otasks_from_list_item(
task.listItem,
task.otask_path,
task.priority,
);
dbg("otask", otask);
if (otask) {
OTASK_LIST.push(otask);
}
}
} while (OTASK_PARSE_QUEUE.length > 0);
console.log("otasks::", OTASK_LIST);
render_sub0_file(OTASK_LIST);
}
let OTASK_PARSE_QUEUE: any = [];
let OTASK_LIST = [];
let DBG = false;
function dbg() {
if (DBG) {
console.log(arguments);
}
}
function queue_otasks_from_list_item(listItem, otask_path, priority = 0) {
OTASK_PARSE_QUEUE.push({
listItem,
otask_path,
priority,
});
}
function queue_otasks_from_file(file, otask_path, priority = 0) {
OTASK_PARSE_QUEUE.push({
file,
otask_path,
priority,
});
}
// parsing helper functions
async function render_sub0_file(otasks) {
const sub0_file = app.vault.getAbstractFileByPath("private/sub0-tasks.md");
if (!(sub0_file instanceof TFile)) {
console.error(
"SystemC2: sub0_file not found:",
"private/sub0-tasks.md",
sub0_file,
);
return;
}
//render sub0
const sub0_otasks = otasks.filter(
(otask) => otask.priority == 0 && !otask.is_file,
);
console.log("sub0 otasks...", sub0_otasks);
let rendered_otasks_string = "";
for (const otask of sub0_otasks) {
const path_part = otask.path.length > 0 ? otask.path.join(": ") + ": " : "";
let content_part = otask.content ? otask.content + "\n" : "";
content_part = content_part
.split("\n")
.map((line) => "\t" + line)
.join("\n"); // add a \t in front of every otask.content line
rendered_otasks_string += `- ${path_part}${otask.longName}\n${content_part}\n`;
}
//console.log("newContent...", rendered_otasks_string)
await replaceHeading(sub0_file, "sub0", rendered_otasks_string);
//render sub-1
}
async function replaceHeading(
file: TFile,
heading: string,
newContent: string,
) {
const data = await this.app.vault.read(file);
// Parse the Markdown into an AST
const tree = REMARK.parse(data) as Root;
// Split new content into AST nodes
const newContentAst = REMARK.parse(newContent) as Root;
const children = tree.children;
const newChildren: Content[] = [];
let insideTarget = false;
let targetLevel = 0;
for (let i = 0; i < children.length; i++) {
const node = children[i];
if (node.type === "heading") {
const headingNode = node as Heading;
const text = headingNode.children
.filter((c) => c.type === "text")
.map((c: any) => c.value)
.join("");
// If we encounter our target heading
if (text.trim() === heading) {
insideTarget = true;
targetLevel = headingNode.depth;
newChildren.push(node); // Keep the heading itself
// Insert new content right after
newChildren.push(...newContentAst.children);
continue;
}
// If we’re currently inside target section and see a heading of same or higher level
if (insideTarget && headingNode.depth <= targetLevel) {
insideTarget = false;
}
}
// If we’re not inside the target section, keep the node
if (!insideTarget) {
newChildren.push(node);
}
}
const newTree: Root = { ...tree, children: newChildren };
// Convert AST back to Markdown
const newMarkdown = await REMARK.stringify(newTree);
await this.app.vault.modify(file, newMarkdown);
}
async function otasks_from_list_item(listItem, otask_path, priority = 0) {
try {
// the main otask this listItem is about
let otask = {
description: [],
actions: [],
path: [],
priority,
};
dbg("SystemC2: otasks_from_list_item", listItem, otask_path);
if (!listItem.children[0].type === "paragraph") {
dbg(
"listItem[0] of a otask item is not a paragraph... the otask:",
listItem,
);
return null;
}
let longName = nodeToString(listItem.children[0]);
dbg("longName:", longName);
// check for [[anotherotask]], call otasks_from_file, return those
const match = longName.match(/\[\[([^\]]+)\]\]/);
if (match) {
const file = app.metadataCache.getFirstLinkpathDest(match[1], "");
if (file) {
const metadata = app.metadataCache.getFileCache(file);
if (metadata?.frontmatter?.tags?.includes("t/otask")) {
queue_otasks_from_file(file, otask_path);
return null;
} else {
dbg(
"SystemC2: listItem with Link to '",
match[1],
"', which is not an otask",
longName,
);
}
}
}
//check for [x] and remove from name
if (longName.startsWith("[ ") || longName.startsWith("[x")) {
longName = longName.slice(4);
}
//check for "ot:" and remove from name
if (longName.startsWith("ot:")) {
longName = longName.slice(4);
}
otask.longName = longName;
otask.short = longName;
otask.path = otask_path;
let new_otask_path = [...otask_path, otask.short];
// go through sub list items
// - if it's a prop for the otask... handle that
// - if it's an "ac:" handle that
// - if it's "ot: " handle as another otask -> recursive call
// - if it's "[ ]" handle as another otask -> recursive call
// - if it's a link to a otask file... handle as another otask -> call otasks_from_file()
// - else add it as description item list to otask
if (!listItem.children || listItem.children.length < 2) {
return otask;
}
// add content as text to otask
if (otask.longName == "hosting updates") {
console.log("listItem... ", listItem.children[1]);
let hii = REMARK.stringify(listItem.children[1]);
console.log("text...", hii);
}
let content_text = REMARK.stringify(listItem.children[1]);
otask.content = content_text;
for (const subListItem of listItem.children[1].children) {
let text = nodeToString(subListItem.children[0]);
// check prop
if (check_otask_prop_from_list_item(subListItem, otask)) {
new_otask_path = [...otask_path, otask.short]; // have to redo it here, in case short changed from aprop
continue; // don't do any other processing on this sub-listItem
}
// remove "[ ]"
if (text.startsWith("[ ") || text.startsWith("[x")) {
text = text.slice(4);
}
// check ac
if (text.startsWith("ac:")) {
otask.actions = [
...otask.actions,
action_from_list_item(subListItem, new_otask_path, otask.priority),
];
continue; // don't do any other processing on this sub-listItem
}
// check ot
if (text.startsWith("ot:")) {
queue_otasks_from_list_item(
subListItem,
new_otask_path,
otask.priority,
);
continue; // don't do any other processing on this sub-listItem
}
// check [ ]
if (text.startsWith("[ ] ") || text.startsWith("[x] ")) {
queue_otasks_from_list_item(
subListItem,
new_otask_path,
otask.priority,
);
continue; // don't do any other processing on this sub-listItem
}
// check link to otask file
const match = text.match(/\[\[([^\]]+)\]\]/);
if (match) {
const file = app.metadataCache.getFirstLinkpathDest(match[1], "");
if (file) {
const metadata = app.metadataCache.getFileCache(file);
if (metadata?.frontmatter?.tags?.includes("t/otask")) {
queue_otasks_from_file(file, new_otask_path, otask.priority);
continue;
} else {
dbg(
"SystemC2: listItem with Link to '",
match[1],
"', which is not an otask",
longName,
);
}
continue; // don't do any other processing on this sub-listItem
}
}
// add as description
const desc = REMARK.stringify(subListItem);
otask.description.push(desc);
}
return otask;
} catch (error) {
console.log("failed to parse list item:", listItem);
}
}
async function otasks_from_file(file: TFile, otask_path, priority = 0) {
try {
dbg("SystemC2: otasks_from_file", file, otask_path);
// the main otask this file is about
let otask = {
description: [],
actions: [],
path: [],
priority,
is_file: true,
};
otask.longName = file.basename;
otask.short = otask.longName;
otask.path = otask_path;
let new_otask_path = [...otask_path, otask.short];
const content = await app.vault.read(file);
// parse listItems at the top... for props
const topListItems = getListItemsBeforeFirstHeading(content);
for (const topListItem of topListItems) {
const text = nodeToString(topListItem);
// check for prop
if (check_otask_prop_from_list_item(topListItem, otask)) {
new_otask_path = [...otask_path, otask.short];
continue; // don't do any other processing on this sub-listItem
}
}
// parse all the sub sections
const SUB_COUNT = 10;
for (let sub_number = 0; sub_number < SUB_COUNT; sub_number++) {
let listItems = await getListUnderHeading(content, "sub" + sub_number);
// "sub" without a number should have priority 5
if (sub_number == 5) {
listItems = listItems.concat(await getListUnderHeading(content, "sub"));
}
//dbg(`sub section ${sub_number} has items:`, listItems)
for (const listItem of listItems) {
// check if listItem is an action...
let text = nodeToString(listItem.children[0]);
if (
text.startsWith("ac:") ||
text.startsWith("[ ] ac:") ||
text.startsWith("[x] ac:")
) {
const action = action_from_list_item(listItem, otask_path);
otask.actions.push(action);
}
queue_otasks_from_list_item(
listItem,
new_otask_path,
otask.priority + sub_number,
);
}
}
return otask;
} catch (error) {
console.log("failed to parse file: ", file.path);
}
}
function action_from_list_item(listItem, otask_path) {
dbg("SystemC2: action_from_list_item", listItem, otask_path);
let action = {};
let text = nodeToString(listItem.children[0]).slice(4);
if (text.startsWith("[")) {
text = text.slice(4);
}
action.name = text;
return action;
}
function check_otask_prop_from_list_item(listItem, otask) {
let text = nodeToString(listItem.children[0]);
for (const prop_name of OTASK_PROP_NAMES) {
if (text.startsWith(prop_name + ":")) {
const value = text.slice(prop_name.length + 2);
if (prop_name == "priority") {
// parse priority as int
otask[prop_name] = parseInt(value);
} else if (prop_name == "outcome") {
// add sub items to it
let fullText = REMARK.stringify(listItem).slice(prop_name.length + 4);
otask[prop_name] = fullText;
} else {
otask[prop_name] = value;
}
return true;
}
}
return false;
}
// Extract plain text from a node
function nodeToString(node: any): string {
let result = "";
visit(node, "text", (textNode: Text) => {
result += textNode.value;
});
return result.trim();
}
async function getListUnderHeading(
markdown: string,
headingText: string,
): string[] {
const tree = REMARK.parse(markdown);
//dbg("tree", tree)
let capture = false;
let items: string[] = [];
for (let i = 0; i < tree.children.length; i++) {
const node: Content = tree.children[i];
// Detect the target heading
if (
node.type === "heading" &&
nodeToString(node).toLowerCase() === headingText.toLowerCase()
) {
capture = true;
continue;
}
// Stop if another heading of the same or higher level comes
if (capture && node.type === "heading") {
break;
}
// Collect list items if capturing
if (capture && node.type === "list") {
for (const li of (node as List).children) {
if (li.type === "listItem") {
items.push(li);
}
}
}
}
return items;
}
function getListItemsBeforeFirstHeading(markdown: string) {
const tree = REMARK.parse(markdown);
let items = [];
for (const node of tree.children) {
if (node.type === "heading") {
// stop at first heading
break;
}
if (node.type === "list") {
for (const li of (node as List).children) {
if (li.type === "listItem") {
items.push(li);
}
}
}
}
return items;
}

544
marts/src/timetrack/mod.rs Normal file
View File

@@ -0,0 +1,544 @@
use std::sync::oneshot;
#[cfg(feature = "target-os")]
use clap::{Arg, ArgAction, ArgMatches, Command};
use generated::spacetime::{Profile, TaskPart};
use mize::out;
use mize::proto::MizeMessage;
use mize::{Mize, MizeError, MizePart, MizePartCreate, MizeResult, mize_err, mize_part};
use spacetimedb_sdk::{DbContext, Table};
use generated::spacetime::{
Action, ActivitiesTableAccess, Activity, DbConnection, TaskPartTableAccess,
activitiesQueryTableAccess, next_action, remove_activity, task_partQueryTableAccess,
update_activity, update_upcoming,
};
use crate::spacetime::SpacetimePart;
use spacetimedb_sdk::TableWithPrimaryKey;
#[mize_part]
#[derive(Default)]
pub struct TimeTrack {
mize: Mize,
}
pub fn timetrack(mize: &mut Mize) -> MizeResult<()> {
#[cfg(feature = "target-os")] {
let mut cli = mize.get_part_native::<crate::CliPart>("cli")?;
cli.set_command("ppc");
cli.subcommand(
Command::new("editActionList").alias("aal").arg(
Arg::new("defaultTemplate")
.short('d')
.action(ArgAction::SetTrue),
),
command_edit_action_list,
)?;
cli.subcommand(Command::new("nextAction").alias("ne").arg(
Arg::new("break")
.short('b')
.action(ArgAction::SetTrue),
).arg(
Arg::new("action")
.trailing_var_arg(true)
.allow_hyphen_values(true)
.num_args(0..), // Accepts 0 or more arguments
), command_next_action)?;
cli.subcommand(
Command::new("printCurrentAction").alias("cu"),
command_current_action,
)?;
cli.subcommand(
Command::new("listActions").alias("la"),
command_list_actions,
)?;
cli.subcommand(
Command::new("listActivities").alias("lac"),
command_list_activities,
)?;
cli.subcommand(
Command::new("editActivities").alias("eac"),
command_edit_activities,
)?;
mize.add_listener(
"timetrack.sub_current",
Box::new(timetrack_sub_current_listener),
)?;
}
mize.register_part(Box::new(TimeTrack { mize: mize.clone() }))?;
Ok(())
}
fn send_current_update_msg(conn_id: u64, data: TaskPart, mize: &mut Mize) -> MizeResult<()> {
let now = fixed_len(data.current.name.clone(), 48);
let next = fixed_len(
data.upcoming
.iter()
.nth(0)
.unwrap_or(&Action {
name: "".to_string(),
activity: None,
task: None,
start: None,
end: None,
duration: None,
})
.name
.clone(),
48,
);
let msg = MizeMessage::new_custom(
"timetrack.current",
format!(" {} NEXT: {} ", now, next),
conn_id,
);
let con = mize.get_connection(conn_id)?;
con.send(msg)?;
Ok(())
}
fn fixed_len(mut buf: String, len: usize) -> String {
if buf.len() > len {
buf.truncate(len - 4); // Cut off extra characters
buf.push_str(" ...");
} else {
while buf.len() < len {
buf.push(' '); // Fill with whitespace
}
}
buf
}
pub fn timetrack_sub_current_listener(msg: MizeMessage, mize: &mut Mize) -> MizeResult<()> {
let spacetime = mize.get_part_native::<SpacetimePart>("spacetime").unwrap();
let con = spacetime.con();
let mut mize_clone = mize.clone();
let mut mize_clone_two = mize.clone();
con.subscription_builder()
.on_applied(move |ctx| {
let data = ctx.db.task_part().identity().find(&ctx.identity()).unwrap();
if let Err(e) = send_current_update_msg(msg.conn_id, data, &mut mize_clone) {
e.log();
}
})
.add_query(|q| q.from.task_part())
.subscribe();
con.db
.task_part()
.on_update(move |_, _old: &TaskPart, new: &TaskPart| {
if let Err(e) = send_current_update_msg(msg.conn_id, new.clone(), &mut mize_clone_two) {
e.log();
}
});
Ok(())
}
impl MizePartCreate for TimeTrack {}
impl MizePart for TimeTrack {
fn name(&self) -> &'static str {
"timetrack"
}
}
#[cfg(feature = "target-os")]
fn command_next_action(matches: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
let con = spacetime.con();
let break_flag = matches.get_flag("break");
let action = matches.get_many::<String>("action")
.map(|vals| vals.map(|s| s.as_str()).collect::<Vec<&str>>().join(" "));
let action = match action {
Some(action) => Some(Action {
name: action.clone(),
activity: None,
task: None,
start: None,
end: None,
duration: None,
}),
None => None,
};
let old_action = get_current_action(&mut mize, con)?;
out!("before action: {}", old_action.name);
let (wait_tx, waiter_rx) = std::sync::oneshot::channel::<()>();
con.reducers.next_action_then(break_flag, action, |_, _| {
wait_tx.send(()).unwrap();
})?;
waiter_rx.recv()?;
let new_action = get_current_action(&mut mize, con)?;
out!("new action: {}", new_action.name);
Ok(())
}
#[cfg(feature = "target-os")]
fn command_current_action(_: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
let action = get_current_action(&mut mize, spacetime.con())?;
out!("{}", action.name);
Ok(())
}
fn get_current_action(_mize: &mut Mize, con: &DbConnection) -> MizeResult<Action> {
let (wait_tx, wait_rx) = std::sync::oneshot::channel::<Action>();
con.subscription_builder()
.on_applied(|ctx| {
let task_part_data = ctx.db.task_part().identity().find(&ctx.identity());
if task_part_data.is_none() {
wait_tx
.send(Action {
name: "fillier".to_string(),
activity: None,
task: None,
start: None,
end: None,
duration: None,
})
.unwrap();
} else {
wait_tx.send(task_part_data.unwrap().current).unwrap();
}
})
.add_query(|q| {
q.from.task_part()
//.r#where(|row| row.identity.eq(con.identity()))
})
.subscribe();
let action = wait_rx.recv().unwrap();
Ok(action)
}
#[cfg(feature = "target-os")]
fn command_list_activities(_: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
let activities = get_activities(&mut mize, spacetime.con())?;
for ac in activities.clone() {
print!("ac {}: {}", ac.id, ac.name);
if !ac.parents_ids.is_empty() {
print!(" -");
}
for child_id in ac.parents_ids {
let child_ac = activities.iter().find(|ac| ac.id == child_id).unwrap();
print!(" {}", child_ac.name);
}
print!("\n")
}
Ok(())
}
#[cfg(feature = "target-os")]
fn command_list_actions(_: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
let actions = get_actions(spacetime.con())?;
for action in actions {
out!("{}", action.name)
}
Ok(())
}
#[cfg(feature = "target-os")]
fn command_edit_action_list(matches: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
// maybe append a template to the action list
if matches.get_flag("defaultTemplate") {
out!("Appending template...");
return Ok(());
}
// get list of actions
let mut actions = get_actions(spacetime.con())?;
// write to tmp file
let tmp_file_content = actions_to_string(&actions)?;
let tmp_file = std::env::temp_dir().join(format!(
"ppc-edit-actions-{}.txt",
chrono::Utc::now().timestamp()
));
std::fs::write(&tmp_file, tmp_file_content)
.map_err(|e| mize_err!("Failed to write temp file: {}", e))?;
// open $EDITOR blocking
crate::cli_spawn(format!("nvim {}", tmp_file.display()))?;
// read file and update db
let new_file_content = std::fs::read_to_string(&tmp_file)
.map_err(|e| mize_err!("Failed to read temp file: {}", e))?;
actions = actions_from_string(new_file_content)?;
set_actions(actions, spacetime.con())?;
// delete tmpfile
let _ = std::fs::remove_file(&tmp_file);
Ok(())
}
fn get_actions(con: &DbConnection) -> MizeResult<Vec<Action>> {
let (exit_tx, exit_rx) = std::sync::oneshot::channel::<Vec<Action>>();
con.subscription_builder()
.on_applied(|ctx| {
let task_part_data = ctx.db.task_part().identity().find(&ctx.identity());
if task_part_data.is_none() {
exit_tx.send(Vec::new()).unwrap();
} else {
exit_tx.send(task_part_data.unwrap().upcoming).unwrap();
}
})
.add_query(|q| {
q.from.task_part()
//.r#where(|row| row.identity.eq(con.identity()))
})
.subscribe();
let activities = exit_rx.recv().unwrap();
Ok(activities)
}
fn set_actions(actions: Vec<Action>, con: &DbConnection) -> MizeResult<()> {
let (wait_tx, waiter_rx) = std::sync::oneshot::channel::<()>();
con.reducers
.update_upcoming_then(actions, |_, _| wait_tx.send(()).unwrap())?;
waiter_rx.recv().unwrap();
Ok(())
}
fn actions_to_string(actions: &Vec<Action>) -> MizeResult<String> {
let mut string = String::new();
for action in actions.iter() {
string.push_str(format!("{}\n", action.name).as_str());
}
Ok(string)
}
fn actions_from_string(string: String) -> MizeResult<Vec<Action>> {
let mut new_actions = Vec::new();
for line in string.lines() {
if line.trim().is_empty() {
continue;
}
let mut action = Action {
name: line.to_owned(),
activity: None,
task: None,
start: None,
end: None,
duration: None,
};
parse_action(&mut action);
new_actions.push(action);
}
Ok(new_actions)
}
fn parse_action(action: &mut Action) {
let _parts: Vec<&str> = action.name.split_whitespace().collect();
}
#[cfg(feature = "target-os")]
fn command_edit_activities(_: &ArgMatches, mut mize: Mize) -> MizeResult<()> {
let mut spacetime = mize.get_part_native::<SpacetimePart>("spacetime")?;
// get list of activities
let activities = get_activities(&mut mize, spacetime.con())?;
let con = spacetime.con();
// write to tmp file
let mut tmp_file_content = String::new();
for ac in activities.iter() {
tmp_file_content.push_str(format!("{} {}", ac.id, ac.name).as_str());
if !ac.parents_ids.is_empty() {
tmp_file_content.push_str(" -");
for parent_id in ac.parents_ids.iter() {
let parent_ac = activities.iter().find(|ac| ac.id == *parent_id).unwrap();
tmp_file_content.push_str(" ");
tmp_file_content.push_str(&parent_ac.name);
}
}
tmp_file_content.push_str("\n");
}
let tmp_file = std::env::temp_dir().join(format!(
"ppc-edit-activities-{}.txt",
chrono::Utc::now().timestamp()
));
std::fs::write(&tmp_file, tmp_file_content)
.map_err(|e| mize_err!("Failed to write temp file: {}", e))?;
loop {
// open $EDITOR blocking
crate::cli_spawn(format!("nvim {}", tmp_file.display()))?;
let new_file_content = std::fs::read_to_string(&tmp_file)
.map_err(|e| mize_err!("Failed to read temp file: {}", e))?;
let mut input = String::new();
let old_activities = get_activities(&mut mize, con)?;
if let Err(err) =
parse_activities_from_text(&mut mize, con, new_file_content, old_activities)
{
out!("error parsing the activities from the file: {:?}", err);
out!("press ENTER to open the file again");
// wait for user input
// so that they can se the err msg, before running nvim again
std::io::stdin().read_line(&mut input)?;
} else {
break;
}
}
// delete tmpfile
let _ = std::fs::remove_file(&tmp_file);
Ok(())
}
fn get_activities(_mize: &mut Mize, con: &DbConnection) -> MizeResult<Vec<Activity>> {
let (exit_tx, exit_rx) = std::sync::oneshot::channel::<Vec<Activity>>();
con.subscription_builder()
.on_applied(|ctx| {
exit_tx.send(ctx.db.activities().iter().collect()).unwrap();
})
.add_query(|q| q.from.activities())
.subscribe();
let activities = exit_rx.recv().unwrap();
Ok(activities)
}
fn parse_activities_from_text(
mize: &mut Mize,
con: &DbConnection,
text: String,
old_activities: Vec<Activity>,
) -> MizeResult<()> {
let mut activities_to_delete = old_activities.clone();
let mut wait_revievers = Vec::new();
for line in text.lines() {
if line == "" {
continue;
}
// split
let mut parts = line.split(" ");
let id: u64 = parts.next().ok_or(mize_err!(".."))?.parse()?;
let name = parts.next().ok_or(mize_err!(".."))?.to_string();
// remove from to-delete list
if let Some(pos) = activities_to_delete.iter().position(|ac| ac.id == id) {
activities_to_delete.remove(pos);
}
// wait rx
let (wait_tx, wait_rx) = oneshot::channel::<()>();
wait_revievers.push(wait_rx);
// update or create activity
if let Some(old_activity) = old_activities.iter().find(|ac| ac.id == id) {
con.reducers.update_activity_then(
Activity {
id,
name,
task: old_activity.task,
parents_ids: old_activity.parents_ids.clone(),
},
move |_, _| {
let _ = wait_tx.send(());
},
)?;
} else {
con.reducers.update_activity_then(
Activity {
id: 0,
name,
task: None,
parents_ids: Vec::new(),
},
move |_, _| {
let _ = wait_tx.send(());
},
)?;
}
}
// remove deleted activities
for activity in activities_to_delete {
let (wait_tx, wait_rx) = oneshot::channel::<()>();
wait_revievers.push(wait_rx);
con.reducers.remove_activity_then(activity, move |_, _| {
let _ = wait_tx.send(());
})?;
}
// wait for db stuff to finish
for wait_rx in wait_revievers {
wait_rx.recv().unwrap();
}
let mut wait_revievers = Vec::new();
// check for declared parents
let new_activities = get_activities(mize, con)?;
out!("new_activities: {:?}", new_activities);
for line in text.lines() {
if line == "" {
continue;
}
// split
let mut parts = line.split(" ");
let _id: u64 = parts.next().ok_or(mize_err!(".."))?.parse()?;
let name = parts.next().ok_or(mize_err!(".."))?.to_string();
// parse parents
let seperator = parts.next();
if seperator.is_some() && seperator.unwrap() == "-" {
let mut parents_ids = Vec::new();
for parent_name in parts {
// find id from old_parts by this name
let res_from_old = old_activities.iter().find(|ac| ac.name == parent_name);
let res_from_new = new_activities.iter().find(|ac| ac.name == parent_name);
let parent_activity = res_from_old.or(res_from_new).ok_or(mize_err!(
"activity '{name}' declares parent '{parent_name}'"
))?;
parents_ids.push(parent_activity.id);
}
let child_activity = new_activities
.iter()
.find(|ac| ac.name == name)
.ok_or(mize_err!(".."))?;
let (wait_tx, wait_rx) = oneshot::channel::<()>();
wait_revievers.push(wait_rx);
con.reducers.update_activity_then(
Activity {
parents_ids,
..child_activity.to_owned()
},
move |_, _| {
let _ = wait_tx.send(());
},
)?;
}
}
// wait for db stuff to finish
for wait_rx in wait_revievers {
wait_rx.recv().unwrap();
}
Ok(())
}