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

View File

@@ -1,38 +0,0 @@
[package]
name = "marts"
description = "marts = mize_parts - parts collection of the Mize framework"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4.5.59", optional = true, features = ["string"] }
flume = "0.12.0"
futures = "0.3.32"
mize = { path = "../mize/", default-features = false }
tokio = "1.49.0"
# https://github.com/rscarson/rustyscript/pull/415
#rustyscript = { git = 'https://github.com/rscarson/rustyscript', rev = 'e39529eac2c5d37cfcbb7d7d63da7264d7a5afdb', optional = true }
#deno_core = { version = "0.380.1", optional = true }
chrono = "0.4.44"
reqwest = { version = "0.13.2", features = ["blocking", "json"] }
serde = "1.0.228"
serde_json = "1.0.149"
rusqlite = { version = "0.31", features = ["bundled"], optional = true }
dioxus = { version = "0.7.1", optional = true }
spacetimedb-sdk = "2.1.0"
generated = { path = "../generated/" }
webbrowser = { version = "1.2.0", optional = true }
openidconnect = { version = "4", features = ["reqwest", "rustls-tls"], optional = true }
[features]
default = ["target-os", "desktop", "server", "client", "target-js",]
target-os = [ "clap", "mize/target-os", "rusqlite", "dioxus", "dep:webbrowser" ]
target-js = [ "mize/target-js" ]
target-obsidian = [ "target-js" ]
desktop = ["dioxus/desktop"]
client = [ "dep:openidconnect" ]
server = []
clap = ["dep:clap"]
[lib]
path = "lib.rs"

View File

@@ -1,421 +0,0 @@
use chrono::{DateTime, TimeDelta, Utc};
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::{path::PathBuf, thread};
// 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"
}
}
impl ClientAuth {
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<()> {
let secrets = self.secrets.clone().ok_or(mize_err!(
"no client secrets, meaning the refresh thread can't use the refresh token"
))?;
self.mize.spawn_async_blocking(
"auth.refresh_flow",
refresh_flow(self.mize.clone(), secrets),
)?;
Ok(())
}
pub fn start_refresh_thread(&mut self) -> MizeResult<()> {
let mut mize_clone = self.mize.clone();
self.mize.spawn_background("auth.refresh", move || loop {
let auth = mize_clone.get_part_native::<ClientAuth>("auth")?;
let to_sleep =
Utc::now() - auth.secrets.as_ref().unwrap().expiry.clone() - TimeDelta::minutes(20);
drop(auth);
println!("refresh_thread: going to sleep for: {}", to_sleep);
thread::sleep(to_sleep.to_std()?);
println!("refresh_thread: sleeping done");
let mut auth = mize_clone.get_part_native::<ClientAuth>("auth")?;
auth.refresh()?;
drop(auth);
})?;
Ok(())
}
}
pub fn client_auth(mize: &mut Mize) -> MizeResult<()> {
let mize_clone = mize.clone();
let mut secrets = match read_secrets(mize) {
Ok(secrets) => Some(secrets),
Err(err) => {
println!("err reading secrets: {:?}", err);
None
}
};
// do a refresh if the tokens have expired
if secrets.is_some() && secrets.clone().unwrap().expiry < Utc::now() {
secrets = Some(
mize.spawn_async_blocking("refresh_low", refresh_flow(mize.clone(), secrets.unwrap()))?,
);
}
#[cfg(feature = "target-os")]
{
let mut cli = mize.get_part_native::<crate::CliPart>("cli")?;
cli.subcommand(Command::new("login"), |_, mut mize| {
let mut auth = mize.get_part_native::<ClientAuth>("client_auth")?;
println!("secrets: {:?}", auth.secrets);
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) => {
println!("err refreshing secrets: {:?}", err);
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone))?
}
}
} else {
mize.spawn_async_blocking("client_auth", auth_flow(mize_clone))?
};
write_secrets(&mut mize, &secrets)?;
auth.secrets = Some(secrets);
Ok(())
});
}
#[cfg(feature = "target-obsidian")]
{}
// check if login token is stil valid refresh if not
/*
if secrets.is_some() {
secrets = Some(mize.spawn_async_blocking(
"client_auth",
refresh_flow(mize.clone(), secrets.clone().unwrap()),
)?);
}
*/
mize.register_part(Box::new(ClientAuth {
secrets,
mize: mize.clone(),
}))
}
fn read_secrets(mize: &mut Mize) -> MizeResult<ClientSecrets> {
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)?;
Ok(secrets)
}
fn write_secrets(mize: &mut Mize, secrets: &ClientSecrets) -> MizeResult<()> {
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 = serde_json::to_string(secrets)?;
std::fs::write(secrets_file_path, secrets_string)?;
// 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;
async fn refresh_flow(mut mize: Mize, old_secrets: ClientSecrets) -> MizeResult<ClientSecrets> {
println!("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) => {
println!("err: {:?}", err);
println!("err: {}", err);
panic!();
}
};
println!(
"If your browser does not open automatically, open this URL in it:\n{}\n",
details.verification_uri_complete().unwrap().secret()
);
println!(
"Or enter this PPC login code:\n{}\n",
details.user_code().secret()
);
#[cfg(feature = "target-os")]
{
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| {
tokio::time::sleep(dur).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

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

View File

@@ -1,431 +0,0 @@
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

@@ -1,677 +0,0 @@
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 crate::cli::CliPart;
use crate::habitica::Habitica;
use crate::SpacetimePart;
use generated::spacetime::{
activitiesQueryTableAccess, next_action, remove_activity, task_partQueryTableAccess,
update_activity, update_upcoming, Action, ActivitiesTableAccess, Activity, DbConnection,
TaskPart, TaskPartTableAccess,
};
use spacetimedb_sdk::{
credentials, DbContext, Error, Event, Identity, Status, SubscriptionHandle, Table,
TableWithPrimaryKey,
};
mod dashboard;
#[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)
{
// refresh the credentials every 23h
// as this is basically running as a daemon
use crate::auth::client::ClientAuth;
let mut auth = mize.get_part_native::<ClientAuth>("auth")?;
auth.refresh()?;
auth.start_refresh_thread()?;
drop(auth);
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);
}
pub fn c2vi_dash_main() -> Element {
let mut current = use_signal_sync(|| "".to_string());
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())
.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(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(())
})
}

View File

@@ -1,170 +0,0 @@
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);
}

View File

@@ -1,102 +0,0 @@
use clap::{ArgMatches, Command};
use std::collections::HashMap;
use mize::{mize_part, Mize, MizePart, MizePartCreate, MizeResult};
#[mize_part("cli")]
#[derive(Default)]
pub struct CliPart {
mize: Mize,
cmd: Option<Command>,
actions: HashMap<
String,
Box<dyn FnOnce(&ArgMatches, Mize) -> MizeResult<()> + Send + Sync + 'static>,
>,
parsers:
Option<Vec<Box<dyn FnOnce(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 cli_part = CliPart {
mize: mize.clone(),
cmd: Some(command),
actions: HashMap::new(),
parsers: Some(Vec::new()),
};
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<()> {
let matches = self.cmd.take().unwrap().get_matches();
let sub_cmd = match matches.subcommand_name() {
Some(sub_cmd) => sub_cmd,
None => {
println!("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");
}
}
impl CliPart {
pub fn subcommand<T: FnOnce(&ArgMatches, Mize) -> MizeResult<()> + Send + Sync + 'static>(
&mut self,
subcmd: Command,
action: T,
) -> &mut Command {
let name = subcmd.get_name().to_string();
let cmd = self.cmd.take().unwrap().subcommand(subcmd);
self.actions.insert(name, Box::new(action));
self.cmd = Some(cmd);
self.cmd.as_mut().unwrap()
}
pub fn add_sub_parser<
T: FnOnce(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 = func(self.mize.clone(), self.cmd.take().unwrap())?;
self.cmd = Some(cmd);
Ok(())
}
}

View File

@@ -1,8 +0,0 @@
{
"name": "@ppc/marts",
"imports": {},
"exports": {
".": "./index.ts"
},
"tasks": {}
}

View File

@@ -1,76 +0,0 @@
(() => {
// habitica.ts
var opts = {
"habitica.api_url": {
env_var_name: "PPC_HABITICA_API_URL",
default_val: "https://habitica.com/api/v3"
},
"habitica.user_id": {
env_var_name: "PPC_HABITICA_USER_ID"
},
"habitica.client_name": {
default_val: "3544a0b8-d71a-46e0-9bb1-6ddbb2abcddb-PPC-Software"
},
"habitica.api_token": {
env_var_name: "PPC_HABITICA_API_TOKEN"
}
};
async function habitica(mize) {
Deno.core.print("hiiiiiiiiiiiiiiiiiiii from habitica");
log("hiiiiiiiiiiiiiiiiiiii");
mize.add_opts(opts);
mize.add_part(new Habitica(mize));
}
async function handleRateLimit(response) {
const limit = response.headers.get("X-RateLimit-Limit") || "NONE";
const remaining = parseInt(response.headers.get("X-RateLimit-Remaining") || "10", 10);
const reset = response.headers.get("X-RateLimit-Reset");
console.log(`RateLimit: ${limit} | Remaining: ${remaining} | Reset: ${reset}`);
if (remaining < 2 && reset) {
const resetDate = new Date(reset);
const now = /* @__PURE__ */ new Date();
const waitMs = resetDate.getTime() - now.getTime() + 1e3;
if (waitMs > 0) {
console.log(`Waiting ${Math.round(waitMs / 1e3)} secs for next rate limit window...`);
await new Promise((resolve) => setTimeout(resolve, waitMs));
}
}
}
var Habitica = class {
mize;
constructor(mize) {
this.mize = mize;
}
async get_tasks(type = "todos") {
return await this.api_request("GET", `tasks/user?type=${type}`);
}
async delete_task(id) {
await this.api_request("DELETE", `tasks/${id}`);
}
async api_request(method, path, extraHeaders = {}, data = {}) {
const mize = this.mize;
if (!path.startsWith("/")) {
path = "/" + path;
}
const headers = Object.assign({
"Content-Type": "application/json",
"x-api-user": mize.get_config("habitica.user_id"),
"x-api-key": mize.get_config("habitica.api_token"),
"x-client": mize.get_config("habitica.client_name")
}, extraHeaders);
const response = await fetch(mize.get_config("habitica.api_url") + path, {
method,
headers,
body: JSON.stringify(data)
});
await handleRateLimit(response);
if (!response.ok) {
console.log("request_url:", mize.get_config("habitica.api_url") + path);
throw new Error(`Habitica API error: ${response.status} ${response.statusText}`);
}
return (await response.json()).data;
}
};
habitica(globalThis.mize);
Deno.core.print("hiiiiiiiiiiiiiiiiiiii from habitica one");
})();

View File

@@ -1,12 +0,0 @@
import * as esbuild from "npm:esbuild";
import { denoPlugin } from "jsr:@deno/esbuild-plugin";
await esbuild.build({
plugins: [denoPlugin()],
entryPoints: ["./habitica.ts"],
outfile: "./deno_dist/habitica.js",
bundle: true,
format: "iife",
});
esbuild.stop();

View File

@@ -1,128 +0,0 @@
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();
println!("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());
println!(
"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;
println!(
"Waiting {} secs for next rate limit window...",
(wait_ms / 1000)
);
sleep(Duration::from_millis(wait_ms));
}
}
}
}
}

View File

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

View File

@@ -1,53 +0,0 @@
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) {
println!("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();
println!("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);
},
);

View File

@@ -1,11 +0,0 @@
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();

View File

@@ -1,136 +0,0 @@
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<()> {
println!("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 {
println!("js thread waiting for smth");
let msg = receiver.recv().unwrap();
println!("js thread got smth");
match msg {
JsRuntimeThreadMessage::RunInitJs(js_code) => {
if let Err(err) = js_runtime.execute_script("[init]", js_code) {
println!("err: {}", err);
}
println!("done running js");
}
JsRuntimeThreadMessage::RunJs(js_code) => {
if let Err(err) = js_runtime.execute_script("[idk]", js_code) {
println!("err: {}", err);
}
println!("done running js");
}
JsRuntimeThreadMessage::DoRunPhase => {
if let Err(err) = js_runtime.execute_script("[runPhase]", "mize.runPhase()") {
println!("err: {}", err);
}
println!("done with runPhase");
return Ok(());
}
}
}
}
enum JsRuntimeThreadMessage {
//Closure(Box<dyn FnOnce(&mut JsRuntime) -> MizeResult<()> + Send>),
//AsyncClosure(BoxClosure),
RunInitJs(FastStaticString),
RunJs(String),
DoRunPhase,
}

View File

@@ -1,18 +0,0 @@
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> {}
}

View File

@@ -1,30 +0,0 @@
#[cfg(feature = "target-os")]
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;
#[cfg(feature = "target-os")]
pub use c2vi::*;
#[cfg(feature = "target-os")]
pub mod spacetime;
#[cfg(feature = "target-os")]
pub use spacetime::*;
pub mod auth {
pub mod client;
pub use client::*;
}
pub use auth::*;
//#[cfg(feature = "target-os")]
//pub mod js;
//#[cfg(feature = "target-os")]
//pub use js::*;

View File

@@ -1,21 +0,0 @@
{
"name": "@ppc/marts",
"version": "1.0.0",
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"@flowershow/remark-wiki-link": "^3.4.0",
"builtin-modules": "^5.0.0",
"esbuild": "^0.27.3",
"mdast": "^3.0.0",
"obsidian": "^1.12.3",
"remark-frontmatter": "^5.0.0",
"remark-gfm": "^4.0.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"typescript": "^5.9.3",
"unified": "^11.0.5",
"unist-util-visit": "^5.1.0"
}
}

View File

@@ -1,46 +0,0 @@
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 self) -> &mut DbConnection {
self.con.as_mut().unwrap()
}
}
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 = DbConnection::builder()
.with_token(Some(auth.id_token()?))
.with_database_name("ppc")
.with_uri(db_url)
//.on_connect(callback)
//.on_disconnect(callback)
//.on_connect_error(callback)
//.with_compression(...)
.build()?;
con.run_threaded();
mize.register_part(Box::new(SpacetimePart {
mize: mize.clone(),
con: Some(con),
}))
}

View File

@@ -0,0 +1,246 @@
use mize::{Mize, MizeResult, MizePart, MizePartCreate, mize_part};
use wasm_bindgen::prelude::*;
use wasm_bindgen_futures::spawn_local;
use js_sys::Function;
use web_sys::HtmlElement;
use matrix_sdk::{
Client, ClientConfig, SyncState,
ruma::{
user_id::UserId,
events::room::message::RoomMessageEventContent,
},
};
mod ui;
use ui::mount_chat_app;
const ROOM_ID: &str = "!XwqdXGPlmaLatdGUiN:ppc.social";
const ACCOUNT_NAME: &str = "default";
const INITIAL_LOAD_LIMIT: usize = 50;
/// Matrix part that manages Matrix client and UI
#[mize_part("matrix")]
#[derive(Clone)]
pub struct MatrixPart {
mize: Mize,
pub client: Option<Client>,
pub timeline: Vec<matrix_sdk::SyncRoomEvent>,
pub message_draft: String,
pub edit_event_id: Option<String>,
}
impl MizePart for MatrixPart {}
impl MizePartCreate for MatrixPart {}
impl MatrixPart {
/// Create a new MatrixPart
pub fn new(mize: Mize) -> Self {
Self {
mize,
client: None,
timeline: Vec::new(),
message_draft: String::new(),
edit_event_id: None,
}
}
/// Initialize the Matrix client from config
pub async fn init_client(&mut self) -> Result<(), String> {
let homeserver = self.mize
.get_config(&format!("matrix.accounts.{}.homeserver", ACCOUNT_NAME))
.map_err(|e| format!("Failed to get homeserver config: {:?}", e))?;
let access_token = self.mize
.get_config(&format!("matrix.accounts.{}.access_token", ACCOUNT_NAME))
.map_err(|e| format!("Failed to get access_token config: {:?}", e))?;
let user_id_str = self.mize
.get_config(&format!("matrix.accounts.{}.user_id", ACCOUNT_NAME))
.map_err(|e| format!("Failed to get user_id config: {:?}", e))?;
let device_id = self.mize
.get_config(&format!("matrix.accounts.{}.device_id", ACCOUNT_NAME))
.map_err(|e| format!("Failed to get device_id config: {:?}", e))?;
let user_id = UserId::parse(&user_id_str)
.map_err(|e| format!("Invalid user_id: {:?}", e))?;
let config = ClientConfig::new()
.homeserver_url(homeserver)
.device_id(device_id);
let client = Client::new_with_config(user_id, Some(access_token), config)
.await
.map_err(|e| format!("Failed to create client: {:?}", e))?;
self.client = Some(client.clone());
// Start sync in background
self.start_sync(client).await?;
Ok(())
}
/// Start sync and set up event listeners
async fn start_sync(&mut self, client: Client) -> Result<(), String> {
let room_id = ROOM_ID.to_string();
let mize = self.mize.clone();
// Start sync
let sync_token = client.sync_token().await;
let mut sync_state = SyncState::new(sync_token);
let client_clone = client.clone();
spawn_local(async move {
loop {
match client_clone.sync(&mut sync_state).await {
Ok(response) => {
// Process sync response - update MatrixPart via mize
if let Some(rooms) = response.rooms {
if let Some(join) = rooms.join {
if let Some(room_events) = join.get(&room_id) {
// Get MatrixPart and update timeline
if let Ok(mut matrix_part) = mize.get_part_native::<MatrixPart>("matrix") {
for event in &room_events.timeline.events {
if event.room_id() == room_id {
matrix_part.timeline.push(event.clone());
// Keep only last INITIAL_LOAD_LIMIT events
if matrix_part.timeline.len() > INITIAL_LOAD_LIMIT {
matrix_part.timeline.remove(0);
}
}
}
}
}
}
}
// Check if we should stop syncing
if sync_state.should_stop() {
break;
}
}
Err(e) => {
console_log!("Sync error: {:?}", e);
break;
}
}
}
});
Ok(())
}
/// Send a message to the room
pub async fn send_message(&mut self) -> MizeResult<()> {
let client = self.client.as_ref()
.ok_or_else(|| mize::error::Error::new("Client not initialized"))?;
if self.message_draft.is_empty() {
return Ok(());
}
let content = RoomMessageEventContent::text_plain(&self.message_draft);
if let Some(_event_id) = &self.edit_event_id {
// Editing existing message - placeholder for now
console_log!("Editing message (not yet implemented)");
}
// Send message
client.send_room_message(ROOM_ID, content).await?;
// Clear draft after sending
self.message_draft.clear();
self.edit_event_id = None;
Ok(())
}
}
/// Entry point for the Matrix part
pub fn part_matrix(mize: &mut Mize) -> MizeResult<()> {
#[cfg(feature = "target_obsidian")] {
use crate::ObsidianPart;
// Get the Obsidian part
let obsidian = mize.get_part_native::<ObsidianPart>("obsidian")?;
// Create and register MatrixPart
let matrix_part = MatrixPart::new(mize.clone());
mize.register_part(Box::new(matrix_part));
// Get the registered MatrixPart to initialize it
let mize_clone = mize.clone();
spawn_local(async move {
if let Ok(mut matrix_part) = mize_clone.get_part_native::<MatrixPart>("matrix") {
if let Err(e) = matrix_part.init_client().await {
console_log!("Failed to initialize Matrix client: {}", e);
}
}
});
// Create a JS function that will be called when the view is opened
let mize_for_view = mize.clone();
let view_creator = Closure::<dyn Fn(JsValue)>::wrap(Box::new(move |leaf: JsValue| {
if let Some(element) = extract_dom_element(leaf) {
// Get MatrixPart state for the UI
if let Ok(matrix_part) = mize_for_view.get_part_native::<MatrixPart>("matrix") {
let timeline = matrix_part.timeline.clone();
let message_draft = matrix_part.message_draft.clone();
let edit_event_id = matrix_part.edit_event_id.clone();
mount_chat_app(element, timeline, message_draft, edit_event_id, mize_for_view.clone());
}
} else {
console_log!("Failed to extract DOM element from leaf");
}
}));
// Convert closure to JS Function
let view_creator_fn: &Function = view_creator.as_ref().unchecked_ref();
// Register the view with Obsidian
ObsidianPart::register_view(&obsidian.plugin(), "matrix-chat-view", view_creator_fn)?;
// Prevent the closure from being dropped
view_creator.forget();
}
Ok(())
}
/// Extract DOM element from Obsidian leaf JSValue
fn extract_dom_element(leaf: JsValue) -> Option<HtmlElement> {
use wasm_bindgen::JsCast;
use js_sys::Reflect;
// Try to get the containerEl from the leaf
let container_value = Reflect::get(&leaf, &"containerEl".into()).ok()?;
let container = container_value.dyn_into::<HtmlElement>().ok()?;
// Create a div to mount into
let doc = web_sys::window()?.document()?;
let mount_point = doc.create_element("div").ok()?;
mount_point.set_attribute("style", "height: 100%; width: 100%;").ok()?;
container.append_child(&mount_point).ok()?;
mount_point.dyn_into::<HtmlElement>().ok()
}
// Console log macros
#[macro_export]
macro_rules! console_log {
($($t:tt)*) => {
web_sys::console::log_1(&format!($($t)*).into());
};
}
#[macro_export]
macro_rules! console_error {
($($t:tt)*) => {
web_sys::console::error_1(&format!($($t)*).into());
};
}

View File

@@ -0,0 +1,269 @@
use dioxus::prelude::*;
use dioxus_web::events::FormEvent;
use mize::Mize;
use matrix_sdk::SyncRoomEvent;
const ROOM_ID: &str = "!XwqdXGPlmaLatdGUiN:ppc.social";
/// Props for the chat component
#[derive(Props, Clone)]
pub struct ChatProps {
pub mize: Mize,
pub initial_timeline: Vec<SyncRoomEvent>,
pub initial_draft: String,
pub initial_edit_id: Option<String>,
}
/// Message component - renders a single message
#[component]
pub fn Message(cx: Scope, event: SyncRoomEvent) -> Element {
let sender = event.sender().to_string();
let timestamp = event.origin_server_ts();
// Extract display name from MXID (basic version)
let display_name = if let Some(at_pos) = sender.find('@') {
if let Some(colon_pos) = sender.rfind(':') {
&sender[at_pos + 1..colon_pos]
} else {
&sender[at_pos + 1..]
}
} else {
&sender
};
// Format timestamp
let time_str = {
let dt = js_sys::Date::new(&(timestamp as f64));
dt.to_locale_string()
};
// Extract message content
let content = event
.as_original()
.and_then(|e| e.content().deserialize().ok())
.and_then(|c: matrix_sdk::ruma::events::room::message::RoomMessageEventContent| {
c.body().map(|s| s.to_string())
})
.unwrap_or_default();
// Check if this is an edit
let is_edit = event
.as_original()
.map(|e| {
e.content()
.get("m.relates_to")
.and_then(|v| v.as_object())
.and_then(|o| o.get("rel_type"))
.and_then(|v| v.as_str())
== Some("m.replace")
})
.unwrap_or(false);
cx.render(rsx! {
div {
class: "matrix-chat-event",
style: "margin: 14px 16px; padding-bottom: 10px; border-bottom: 1px solid var(--background-modifier-border);",
div {
class: "matrix-chat-msghead",
style: "font-size: 1em; font-weight: 500; color: var(--interactive-accent); display: flex; align-items: center; gap: 10px;",
span { display_name }
span {
style: "font-size: 0.85em; color: var(--text-muted); margin-left: auto;",
{time_str.clone()}
if is_edit { " · edited" }
}
}
div {
class: "matrix-chat-msgbody",
style: "margin: 6px 0 0 0; font-size: 1em;",
{content}
}
}
})
}
/// Editor component - message input area
#[component]
pub fn Editor(cx: Scope, mize: Mize, draft: UseSignal<String>, edit_id: UseSignal<Option<String>>) -> Element {
let on_input = move |evt: FormEvent| {
draft.set(evt.value.clone());
};
let on_send = move |_| {
let mize_clone = mize.clone();
let draft_val = draft.read().clone();
let edit_id_val = edit_id.read().clone();
cx.spawn(async move {
if let Ok(mut matrix_part) = mize_clone.get_part_native::<crate::matrix::MatrixPart>("matrix") {
matrix_part.message_draft = draft_val;
matrix_part.edit_event_id = edit_id_val;
if let Err(e) = matrix_part.send_message().await {
console_log!("Failed to send message: {:?}", e);
}
// Clear local draft
draft.set(String::new());
edit_id.set(None);
}
});
};
let on_keydown = move |evt: dioxus_web::events::KeyboardEvent| {
if (evt.ctrl_key() || evt.meta_key()) && evt.key() == "Enter" {
on_send(evt);
}
};
let on_cancel = move |_| {
draft.set(String::new());
edit_id.set(None);
};
cx.render(rsx! {
div {
class: "matrix-chat-editor",
style: "border-top: 1px solid var(--background-modifier-border); background: var(--background-primary); padding: 8px 12px;",
div {
class: "matrix-chat-editor-inner",
style: "border: 1px solid var(--background-modifier-border); border-radius: 6px; overflow: hidden; background: var(--background-secondary);",
textarea {
r#type: "text",
rows: "3",
style: "width: 99%; min-height: 40px; resize: vertical; background: transparent; border: none; outline: none; padding: 8px; font-family: inherit; font-size: inherit; color: var(--text-normal);",
value: "{draft}",
oninput: on_input,
onkeydown: on_keydown,
}
}
button {
r#type: "button",
style: "margin-top: 3px; float: right; padding: 6px 14px; font-size: 0.97em; border-radius: 5px; border: none; background: var(--interactive-accent); color: white; cursor: pointer;",
onclick: on_send,
if edit_id.read().is_some() { "Save" } else { "Send" }
}
if edit_id.read().is_some() {
button {
r#type: "button",
style: "margin-right: 12px; padding: 6px 12px; font-size: 0.93em; border-radius: 5px; border: none; color: var(--text-normal); background: var(--background-secondary); cursor: pointer;",
onclick: on_cancel,
"Cancel"
}
}
}
})
}
/// Timeline component - list of messages
#[component]
pub fn Timeline(cx: Scope, mize: Mize, timeline: UseSignal<Vec<SyncRoomEvent>>) -> Element {
// Periodically refresh timeline from MatrixPart
cx.use_effect((), move |_| {
let mize_clone = mize.clone();
let timeline_clone = timeline.clone();
// Set up interval to poll for updates
let interval = js_sys::set_interval(
&Closure::<dyn FnMut()>::wrap(Box::new(move || {
if let Ok(matrix_part) = mize_clone.get_part_native::<crate::matrix::MatrixPart>("matrix") {
*timeline_clone.write() = matrix_part.timeline.clone();
}
})),
1000, // 1 second
);
// Cleanup on unmount
move || {
js_sys::clear_interval(interval);
}
});
cx.render(rsx! {
div {
class: "matrix-chat-scroll",
style: "flex-grow: 1; overflow-y: auto; padding: 8px 0; background: var(--background-secondary);",
// Room name header
div {
class: "matrix-chat-roomname",
style: "font-weight: 600; text-align: center; font-size: 1.1em; margin: 2px 0 12px 0; border-bottom: 1px solid var(--background-modifier-border); color: var(--text-accent); background: var(--background-primary);",
"Matrix Chat - {ROOM_ID}"
}
// Messages
for event in timeline.read().iter() {
rsx! { Message { event: event.clone() } }
}
}
})
}
/// Root chat component
#[component]
pub fn Chat(cx: Scope, props: ChatProps) -> Element {
let timeline = use_signal(&cx, || props.initial_timeline);
let draft = use_signal(&cx, || props.initial_draft);
let edit_id = use_signal(&cx, || props.initial_edit_id);
cx.render(rsx! {
div {
class: "matrix-chat-root",
style: "height: 100%; display: flex; flex-direction: column; background: var(--background-primary); color: var(--text-normal);",
// Title
div {
class: "matrix-chat-title",
style: "padding: 12px; font-weight: bold; font-size: 1.2em; text-align: center; border-bottom: 1px solid var(--background-modifier-border);",
"Matrix Chat"
}
// Info
div {
class: "matrix-chat-info",
style: "font-size: 0.85em; color: var(--text-muted); text-align: center; margin-bottom: 5px;",
"Room: {ROOM_ID}"
}
// Timeline
Timeline { mize: props.mize.clone(), timeline }
// Editor
Editor { mize: props.mize.clone(), draft, edit_id }
}
})
}
/// Mount the Dioxus app into a DOM element
pub fn mount_chat_app(
element: web_sys::HtmlElement,
initial_timeline: Vec<SyncRoomEvent>,
initial_draft: String,
initial_edit_id: Option<String>,
mize: Mize,
) {
dioxus_web::launch(
dioxus::prelude::App::new(move || rsx! {
Chat {
mize: mize.clone(),
initial_timeline: initial_timeline.clone(),
initial_draft: initial_draft.clone(),
initial_edit_id: initial_edit_id.clone(),
}
}),
element,
);
}
// Console log macro
#[macro_export]
macro_rules! console_log {
($($t:tt)*) => {
web_sys::console::log_1(&format!($($t)*).into());
};
}

View File

@@ -1,538 +0,0 @@
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;
}