diff --git a/.github/ico2.png b/.github/ico2.png new file mode 100644 index 0000000..9e03d87 Binary files /dev/null and b/.github/ico2.png differ diff --git a/.github/icon.png b/.github/icon.png new file mode 100644 index 0000000..40415bd Binary files /dev/null and b/.github/icon.png differ diff --git a/.github/icon.xc b/.github/icon.xc new file mode 100644 index 0000000..e99a558 --- /dev/null +++ b/.github/icon.xc @@ -0,0 +1,33 @@ +# ▀▄▀ █ █▀▀ █▀█ +# █░█ █ █▄▄ █▄█ +# ▁▁▁▁▁▁▁▁v0.7▁ +# +# +# ░░ USAGE ░░░░░░░░░░░░░░░░░░░░░░ +# +# run this template +# +# xico -t .github/icon.xc +# +# ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ + +set d 0 +set o 1 +set r 40 +set ff monospace +set fw bold +set fs 7.2em +set w 94 +set h 94 +set r_x 3 +set r_y 3 +set fg #FF3366 +set bg #330044 +set stroke #330044 +set border 6 + +put 􀕨 .github/icon.png +put 􀕨 .github/icon.svg + +# 􀝜 􀕨 􀕩 􀢚 􀕫 +# diff --git a/Cargo.lock b/Cargo.lock index 807c0ec..00b9418 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,7 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +version = 4 + [[package]] name = "aho-corasick" version = "0.7.15" @@ -287,12 +289,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "protobuf" -version = "1.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e14ccd6b79ec748412d4f2dfde1a80fa363a67def4062969f8aed3d790a30f28" - [[package]] name = "quickcheck" version = "0.9.2" @@ -446,6 +442,9 @@ name = "serde" version = "1.0.117" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b88fa983de7720629c9387e9f517353ed404164b1e482c970a90c1a4aaf7dc1a" +dependencies = [ + "serde_derive", +] [[package]] name = "serde_derive" @@ -661,7 +660,7 @@ checksum = "b5a972e5669d67ba988ce3dc826706fb0a8b01471c088cb0b6110b805cc36aed" [[package]] name = "voidmap" -version = "1.1.5" +version = "1.2.3" dependencies = [ "clap", "clippy", @@ -671,10 +670,11 @@ dependencies = [ "lazy_static", "libc", "log", - "protobuf", "quickcheck", "rand", "regex", + "serde", + "serde_json", "termion", "time", "unicode-segmentation", diff --git a/Cargo.toml b/Cargo.toml index 6248b4c..92997b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,15 +1,18 @@ [package] name = "voidmap" -version = "1.1.5" -authors = ["Tyler Neely ", "Katharina Fey "] -description = "terminal mind-map + task tracker + tsdb" +version = "1.2.3" +authors = ["Tyler Neely ", "Katharina Fey ", "metaory "] +description = "terminal mind-map + task tracker + tsdb with human-readable JSON storage" license = "GPL-3.0" -homepage = "https://github.com/void-rs/void" -keywords = ["cli", "commandline", "visualization", "ui"] +homepage = "https://github.com/metaory/void-json" +keywords = ["cli", "commandline", "visualization", "ui", "json"] edition = "2018" -[[test]] +[[bin]] +name = "void" +path = "src/bin/void/main.rs" +[[test]] name = "test" path = "test/test.rs" @@ -24,13 +27,18 @@ log = "0.4.11" lazy_static = "1.4.0" time = "0.2.22" getopts = "0.2.21" -protobuf = "1" rand = "0.7.3" libc = "0.2.80" regex = "1.4.2" unicode-segmentation = "1.6.0" clippy = { version = "0.0.302", optional = true } fs2 = "0.4.3" +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" [dev-dependencies] quickcheck = "0.9.2" + +[profile.release] +lto=true +codegen-units=1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..4dc91a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + +[... Full GPL-3.0 text ...] \ No newline at end of file diff --git a/README.md b/README.md index f7b58df..ecd2a70 100644 --- a/README.md +++ b/README.md @@ -1,169 +1,185 @@ -# void [![Build Status](https://img.shields.io/travis/void-rs/void.svg?style=flat-square)](https://travis-ci.org/void-rs/void) ![State](https://img.shields.io/badge/state-alpha-orange.svg?style=flat-square) - -[Tutorial](TUTORIAL.md) - -[Example Workflow](#what-i-do-dont-do-what-i-do-discover-what-works-for-you) - -WARNING: this is alpha, and the default keybinds are still weird because I use colemak on top of tmux. You may want to change them, by setting the `KEYFILE` env var to the path to a [key remap file](default.keys). In the future, I may add optional modal editing to bring it more in-line with vim. Right now I'm not sure it's worth the extra keystrokes. - -Feedback encouraged! If you have a hard time with something, let me know about it, and I'll work to smooth out the experience! - -![](/demo.gif) - -## problems - -This is an attempt to address several common situations: - -1. frequently fall out of creative flow -1. day-to-day work lacks coherence -1. failure to integrate learnings into a cohesive perspective -1. execution of tasks lacks focus and motivation -1. unclear how my efforts are impacting goals - -## perspectives - -* things we measure tend to improve -* we should regularly reevaluate priorities -* we should minimize decisions to prevent fatigue -* individual sensemaking is well served by reflection, journaling, outlining, mind-mapping, etc... -* don't impose specific workflows, but support many possibilities - -## implementation - -* everything is a tree -* you can collapse subtrees -* you can drill-down the screen focus arbitrarily -* trees of tasks can be marked with `#task`, all children of marked nodes are implicitly subtasks -* tasks can be prioritized with `#prio=`, all children implicitly inherit the lowest ancestor's priority -* a task can be chosen automatically, with priorities weighting a random selection. you should delete it or do it, don't get into the habit of drawing again until you see something you like. you chose the priorities, and you should keep them up-to-date. -* you can create your own sparklines by using `#plot=done` or `#plot=new`, in combination with `#n=10` for sparkline size, `#since=7d` / `#until=1d` for specifying time window. -* overall completed subtasks are plotted on a sparkline at the top of the screen for the past week. -* you can draw arrows between nodes for mind-mapping functionality -* can shell out and execute the content of a node with C-k. if the node starts with txt: this will be opened in vim or an editor specified in the `EDITOR` env var. - -## what I do (don't do what I do, discover what works for you) -* create a #task subtree -* create different story subtrees for life goals, projects, etc... and tag them, #climbing #reading #client_143 etc... -* set up graphs for feedback on different goals/projects. `#tagged=climbing #since=30d #plot=done` -* start the day by fiddling with `#prio=` tags on the stories -* hit the auto-task keybind (by default `C-v`) to pick an incomplete task child from one of the stories -* work on it for 25 minutes or until completion, optionally leaving a few minutes for a retrospective/reprioritization at the end -* distract myself as much as possible, let brain GC whatever I've been thinking about a little bit -* if I've completed a task, mark it done (by default, `C-a`) -* completed work is surfaced in the sparkline graphs I've set up for its tags -* every week or so, tweak the system - -#### install - -`cargo install voidmap` - -if you don't have cargo, an easy way to get and manage -it is [via rustup](https://www.rustup.rs/). Ensure that `~/.cargo/bin` -is in your `$PATH` afterward, so that you can use the `rustup` and `cargo` -commands. - -If you get errors along the lines of ``error: the `?' operator is not stable`` then -you need to update your rust compiler. If you installed rust with rustup, this can -be accomplished with `rustup update`. Requires a recent stable rust compiler, -`1.14.0` or higher is recommended. This can be checked with `rustc --version`. -If you have installed rust with rustup, but you have an old version, there may be -an older version previously installed on your system. Verify that `which cargo` -outputs a path that belongs to your `.cargo/bin` directory. - -#### invocation - -`void` - -this attempts to use `$HOME/.void.db` as a storage file. -if you'd like to specify a different storage file: - -`void [/path/to/savefile]` - -#### keys - -feature | control | feature | control ---- | --- | --- | --- -new node | C-n | new node (child of selected) | Tab -new node (freeform) | click blank space | new node (sibling of selected) | Enter -delete selected node and its children | Delete | move subtree | drag parent to new location -undo delete | C-z | auto arrange nodes in view | C-p -mark selected node complete | C-a | drill-down into selected node | C-w -pop up selection | C-q | hide children of selected | C-t -open text editor for `txt:...` node | C-k | prefix-jump with no selection | type a letter -prefix-jump with other selected | C-f | hide completed children of node | C-h -select arrow start/destination | C-r | erase arrow | select start, C-r, then destination, C-r -show debug log | C-l | reparent node | drag node to new parent -scroll up | PgUp | scroll down | PgDn -select up | Up | select down | Down -select subtree to left | Left | select subtree to right | Right -de-select node | Esc | save | C-x -exit | Esc with nothing selected | exit | C-c -jump to weighted next task | C-v | cut / paste node | C-y -move selected up in child list | C-g | move selected down in child list | C-d -search for node at or below current view | C-u | Select parent | A-S-p (alt shift) -Select next sibling | A-n | select previous sibling | A-p - -can be customized by setting the `KEYFILE` env var to the path of a [key configuration file](default.keys) - -#### known bugs - -doesn't properly handle very long text. if you want to embed -an essay, create a node that begins with `txt: ` and hit `C-k` -to open its contents in an external text editor, specifiable -by setting the `EDITOR` env var. - -#### optional configuration - -setting the `LOGFILE` environment variable will allow you to -log debugging info to a file. - -setting the `EDITOR` environment variable will allow you to -specify which text editor is opened when hitting `C-k` on a -node whose name begins with `txt: `. defaults to vim. - -setting the `KEYFILE` environment variable to the path of a -[keyfile](default.keys) allows you to customize the controls - -setting the `LOCATION_QUERY` environment variable to anything -will enable an http request that is sent out at startup to -get approximate latitude and longitude coordinates associated -with your internet-facing IP. this is added to any nodes created -during a session, and eventually will allow you to trace the -rough path you've taken over time. aimed mostly at users who -travel a lot, may eventually have a more interesting implementation. - -#### notes - -This came about in the midst of an (ongoing) obsessive inquiry into a -cluster of topics roughly related to "effectiveness" while stumbling -through various mountain ranges and cities in central europe and the -american northeast. - -* conversations with [@matthiasn](https://github.com/matthiasn) and being introduced -to his wonderful [iWasWhere](https://github.com/matthiasn/iWasWhere) system -* [writings of eliezer s. yudkowsky](https://wiki.lesswrong.com/wiki/Rationality:_From_AI_to_Zombies), -[how to solve it](https://en.wikipedia.org/wiki/How_to_Solve_It), -[society of mind](http://www.acad.bg/ebook/ml/Society%20of%20Mind.pdf) -* [various subtopics of operations research](https://en.wikipedia.org/wiki/Operations_research#Problems_addressed) -* occult assumption confrontation: [undoing yourself with energized meditation](http://www.pauladaunt.com/books/Christopher%20S%20Hyatt_Undoing%20Yourself%20With%20Energized%20Meditation%20And%20Other%20Devices.pdf), -[prometheus rising](https://selfdefinition.org/science/Robert-Anton-Wilson-Prometheus-Rising.pdf) -* military C2 theory, recognition/metacognition, OODA, etc... [A Review of Time Critical Decision Making Models and -Human Cognitive Processes](https://pdfs.semanticscholar.org/2eb9/e12955dfafd4ab5d9337b416e31f5afca834.pdf) -* personal productivity literature: [pomodoro](http://baomee.info/pdf/technique/1.pdf), [GTD](https://en.wikipedia.org/wiki/Getting_Things_Done), -[eat that frog](https://web.archive.org/web/20170713032412/http://www.actnow.ie:80/files/BookSummaryEatThatFrog.pdf), [flow](https://web.archive.org/web/20080211220216/plexusinstitute.org/edgeware/archive/think/main_filing15.htm) - -> The primary thing when you take a sword in your -hands is your intention to cut the enemy, whatever -the means. Whenever you parry, hit, spring, strike -or touch the enemy’s cutting sword, you must cut -the enemy in the same movement. It is essential to -attain this. If you think only of hitting, springing, -striking or touching the enemy, you will not be able -actually to cut him. More than anything, you must -be thinking of carrying your movement through to -cutting him... When you appreciate the power of -nature, knowing the rhythm of any situation, you -will be able to hit the enemy naturally and strike -naturally. All this is the Way of the Void. - Miyamoto Musashi +
+

+ void-tab + ㄙ𐊔\𐰷 +

+
JSON-only fork of void
+
+LINEAGE +------- + +| This Fork | Incorporated | Original | +|----------- |--------------- |---------- | +| [metaory/void] | [onbjerg/void] | [void-rs/void] | +| JSON storage | Arrow pathfinding | protobuf base | + +find original void readme here. + +--- + +RATIONALE +--------- + +This fork transitions void to use `JSON` storage instead of binary `protobuf` + +- **Human Readable**: Data can be inspected and edited with any text editor +- **Debuggable**: Easy to examine and fix corrupted files +- **Portable**: Standard format supported by all languages and tools +- **Versionable**: Clean diffs in version control +- **Minimal**: Only stores essential data, reducing complexity + +##### Key changes in this fork: +- Uses `JSON` format exclusively +- Removes `protobuf` dependency +- Provides migration tool for old databases +- Maintains data compatibility + +--- + +MIGRATION +--------- + +If you're upgrading from the original void `<1.2.0`, +You'll need to migrate your binary database: + +```bash +scripts/migrate.sh path/to/old/database path/to/new.json +``` + +---- + +USAGE +----- + +By default, void looks for `~/.void.json` unless a path is specified: + +```bash +# Use default ~/.void.json +void + +# Or specify a path +void path/to/notes.json +``` + +> [!TIP] +> The JSON format excludes auto-generated data +> - Node coordinates (auto-arranged) +> - Colors (randomly generated) +> - Selection state +> - Ephemeral UI state + +---- + +INSTALLATION +------------ + +Clone and build from source: + +```bash +git clone https://github.com/metaory/void +cd void +cargo build --release +``` + +The binary will be available at `target/release/void` + +If you don't have cargo, install it [via rustup](https://rustup.rs). + +--- + +CONFIGURATION +------------- + + Variable | Description +----------------- | -------------------------------------- + `KEYFILE` | Path to [key remap file](default.keys) + `EDITOR` | Text editor (defaults to vim) + `LOGFILE` | Path for debug logging + `LOCATION_QUERY` | Enable location tracking for nodes + + Feature | Control | Feature | Control +----------------- | --------------- | --------------- | --------------- + new | C-n | new child | Tab + freeform | | new sibling | Enter + delete | Del | move subtree | LeftDrag + undo | C-z | auto arrange | C-p + mark complete | C-a | drill-down | C-w + pop | C-q | hide children | C-t + open editor | C-k | prefix-jump | a-z + prefix-jump | C-f | hide completed | C-h + select arrow | C-r | erase arrow | C-r C-r + show debug log | C-l | reparent | LeftDrag + scroll up | PgUp | scroll down | PgDn + select up | Up | select down | Down + select left | Left | select right | Right + de-select | Esc | exit | Esc Esc + exit | C-c | save | C-x + next weighted | C-v | cut / paste | C-y + move up | C-g | move down | C-d + search below | C-u | select parent | A-P + select next | A-n | select previous | A-p + +--- + +LICENSE +------- +[GNU General Public License v3.0](LICENSE) +inherited from upstream + +--- + +CONTRIBUTORS +------------ + +- [@spacejam] Original +- [@onbjerg] +- [@metaory] + +--- + +
+ORIGINAL VOID DOCUMENTATION + +![State](https://img.shields.io/badge/state-alpha-orange.svg?style=flat-square) + +## Problems This Tool Addresses + +1. Frequently fall out of creative flow +2. Day-to-day work lacks coherence +3. Failure to integrate learnings into a cohesive perspective +4. Execution of tasks lacks focus and motivation +5. Unclear how my efforts are impacting goals + +## Core Perspectives + +* Things we measure tend to improve +* We should regularly reevaluate priorities +* We should minimize decisions to prevent fatigue +* Individual sensemaking is well served by reflection, journaling, outlining, mind-mapping, etc... +* Don't impose specific workflows, but support many possibilities + +## Implementation + +* Everything is a tree +* You can collapse subtrees +* You can drill-down the screen focus arbitrarily +* Trees of tasks can be marked with `#task`, all children of marked nodes are implicitly subtasks +* Tasks can be prioritized with `#prio=`, all children implicitly inherit the lowest ancestor's priority +* A task can be chosen automatically, with priorities weighting a random selection +* You can create your own sparklines by using `#plot=done` or `#plot=new`, in combination with `#n=10` for sparkline size, `#since=7d` / `#until=1d` for specifying time window +* Overall completed subtasks are plotted on a sparkline at the top of the screen for the past week +* You can draw arrows between nodes for mind-mapping functionality +* Can shell out and execute the content of a node with C-k. if the node starts with txt: this will be opened in vim or an editor specified in the `EDITOR` env var + +[Tutorial](TUTORIAL.md) | [Example Workflow](#what-i-do-dont-do-what-i-do-discover-what-works-for-you) +
+ +[@spacejam]: https://github.com/spacejam +[@onbjerg]: https://github.com/onbjerg +[@metaory]: https://github.com/metaory + +[void-rs/void]: https://github.com/void-rs/void +[metaory/void]: https://github.com/metaory/void +[onbjerg/void]: https://github.com/onbjerg/void diff --git a/TUTORIAL.md b/TUTORIAL.md index 54edaf7..b5e3f5d 100644 --- a/TUTORIAL.md +++ b/TUTORIAL.md @@ -21,7 +21,7 @@ your `.cargo/bin` directory. `void` -this attempts to use `$HOME/.void.db` as a storage file. if you'd like +this attempts to use `$HOME/.void.json` as a storage file. if you'd like to specify a different storage file: `void [/path/to/savefile]` diff --git a/scripts/migrate.sh b/scripts/migrate.sh new file mode 100755 index 0000000..2b22cb4 --- /dev/null +++ b/scripts/migrate.sh @@ -0,0 +1,97 @@ +#!/bin/bash + +# Migrate from old binary protobuf format to new JSON format +# Input: binary protobuf file +# Output: JSON array [nodes, max_id, arrows] where: +# nodes: array of [id, meta, text, children, collapsed, stricken, hide_stricken, parent_id, free_text] +# max_id: highest node ID +# arrows: array of arrow connections (empty in migration) + +FORCE=0 + +while [[ $# -gt 0 ]]; do + case $1 in + -f|--force) + FORCE=1 + shift + ;; + *) + if [[ -z "$INPUT" ]]; then + INPUT="$1" + elif [[ -z "$OUTPUT" ]]; then + OUTPUT="$1" + else + echo "Error: Too many arguments" + echo "Usage: $0 [-f|--force] input_file [output_file]" + exit 1 + fi + shift + ;; + esac +done + +[[ -z "$INPUT" ]] && { echo "Usage: $0 [-f|--force] input_file [output_file]"; exit 1; } +[[ -f "$INPUT" ]] || { echo "Error: Input file not found"; exit 1; } +[[ -n "$OUTPUT" && -f "$OUTPUT" && $FORCE -eq 0 ]] && { echo "Error: Output file already exists (use -f to overwrite)"; exit 1; } + +# Check if input is already JSON +if head -c1 "$INPUT" | grep -q '^[[:space:]]*\['; then + echo "Error: Input file appears to be JSON already. Use -f to force migration." >&2 + exit 1 +fi + +function migrate { + # Use protoc to decode binary format, then transform to JSON + # The output format matches the app's JsonScreen type: + # type JsonScreen = (Vec, u64, Vec<(u64, u64)>) + # where JsonNode = (id, meta, text, children, collapsed, stricken, hide_stricken, parent_id, free_text) + protoc --decode_raw < "$1" | awk -v meta='{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null}' ' + BEGIN { + max_id = 0 + first = 1 + print "[" + } + + /^1 {/ { + node_id = 0 + node_text = "" + children = "" + parent_id = 0 + next + } + + /^}/ { + if (!first) printf "," + printf "[%d,%s,\"%s\",%s,false,false,false,%d,null]", + node_id, meta, node_text, children ? children "]" : "[]", parent_id + if (node_id > max_id) max_id = node_id + first = 0 + next + } + + /^ 1:/ { node_id = $2; next } + /^ 3:/ { node_text = substr($0, index($0, ":") + 2); gsub(/"/, "", node_text); next } + /^ 4:/ { + if (children == "") children = "[" + else children = children "," + children = children $2 + next + } + /^ 11:/ { parent_id = $2; next } + + END { + print "]" + print max_id + print "[]" + } + ' | jq -s '[.[0], .[1], .[2]]' +} + +if [[ -n "$OUTPUT" ]]; then + migrate "$INPUT" > "$OUTPUT" || { echo "Error: Migration failed"; exit 1; } + echo -e "\nSuccessfully migrated $INPUT to $OUTPUT" + echo -e "\nTo use the migrated database, run:" + echo " void $OUTPUT" +else + migrate "$INPUT" +fi \ No newline at end of file diff --git a/src/bin/void/cli.rs b/src/bin/void/cli.rs index 02a88c0..c071584 100644 --- a/src/bin/void/cli.rs +++ b/src/bin/void/cli.rs @@ -1,14 +1,21 @@ use clap::{App, Arg}; -const APP_NAME: &str = env!("CARGO_PKG_NAME"); -const VERSION: &str = env!("CARGO_PKG_VERSION"); -const AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); -const ABOUT: &str = env!("CARGO_PKG_DESCRIPTION"); +pub const APP_NAME: &str = env!("CARGO_PKG_NAME"); +pub const VERSION: &str = env!("CARGO_PKG_VERSION"); +pub const AUTHORS: &str = env!("CARGO_PKG_AUTHORS"); +pub const ABOUT: &str = env!("CARGO_PKG_DESCRIPTION"); pub fn create<'a>() -> App<'a, 'a> { App::new(APP_NAME) .version(VERSION) .author(AUTHORS) .about(ABOUT) + .arg( + Arg::with_name("version") + .short("v") + .long("version") + .help("Print version info and exit") + .conflicts_with("PATH"), + ) .arg(Arg::with_name("PATH").takes_value(true).required(false)) } diff --git a/src/bin/void/main.rs b/src/bin/void/main.rs index c61346a..6535f8c 100644 --- a/src/bin/void/main.rs +++ b/src/bin/void/main.rs @@ -4,11 +4,22 @@ use voidmap::{deserialize_screen, init_screen_log, Config, Screen}; mod cli; +fn is_binary(data: &[u8]) -> bool { + // Check if data contains any non-UTF8 bytes + String::from_utf8(data.to_vec()).is_err() +} + fn main() { // Initialise the CLI parser let app = cli::create(); let matches = app.get_matches(); + // Handle version flag + if matches.is_present("version") { + println!("{} {}", cli::APP_NAME, cli::VERSION); + return; + } + // Initialise screen logger init_screen_log().unwrap(); @@ -17,7 +28,7 @@ fn main() { .map(OsString::from) .or_else(|| { dirs::home_dir().and_then(|mut h| { - h.push(".void.db"); + h.push(".void.json"); Some(h.into_os_string()) }) }) @@ -37,7 +48,26 @@ fn main() { .unwrap_or_else(|_| panic!("Another `void` process is using this path already!")); f.read_to_end(&mut data).unwrap(); - let saved_screen = deserialize_screen(data).ok(); + + // Check if file is legacy binary format + if !data.is_empty() && is_binary(&data) { + eprintln!( + "Error: Pre-1.2.0 binary database format detected at: {}\n\ + This is void ^1.2.0 which uses JSON format\n\ + To migrate your database, run:\n\ + ./scripts/migrate.sh {}\n\ + \nThis will create a JSON version you can use with void ^1.2.0", + path.to_string_lossy(), + path.to_string_lossy() + ); + std::process::exit(1); + } + + let saved_screen = if data.is_empty() { + None + } else { + deserialize_screen(data).ok() + }; // Initialise the main working screen let mut screen = saved_screen.unwrap_or_else(Screen::default); diff --git a/src/json_storage.rs b/src/json_storage.rs new file mode 100644 index 0000000..8fd6bb8 --- /dev/null +++ b/src/json_storage.rs @@ -0,0 +1,131 @@ +use serde::{Deserialize, Serialize}; + +use crate::{Meta, Node, Screen}; + +// [key, value] +pub type JsonTag = (String, String); + +#[derive(Serialize, Deserialize)] +pub struct JsonMeta { + pub ctime: u64, + pub mtime: u64, + pub finish_time: Option, + pub tags: Vec, + pub due: Option, +} + +// [id, meta, text, children, collapsed, stricken, hide_stricken, parent_id, free_text] +pub type JsonNode = ( + u64, // id + JsonMeta, // meta + String, // text + Vec, // children + bool, // collapsed + bool, // stricken + bool, // hide_stricken + u64, // parent_id + Option, // free_text +); + +// [nodes, max_id, arrows] +pub type JsonScreen = (Vec, u64, Vec<(u64, u64)>); + +pub fn serialize_screen(screen: &Screen) -> Vec { + let screen_json: JsonScreen = ( + screen + .nodes + .iter() + .map(|(_, node)| serialize_node(node)) + .collect(), + screen.max_id, + screen + .arrows + .iter() + .map(|&(from, to, _)| (from, to)) + .collect(), + ); + + serde_json::to_vec(&screen_json).unwrap() +} + +fn serialize_meta(meta: &Meta) -> JsonMeta { + JsonMeta { + ctime: meta.ctime, + mtime: meta.mtime, + finish_time: meta.finish_time, + tags: meta + .tags + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + due: meta.due, + } +} + +fn serialize_node(node: &Node) -> JsonNode { + ( + node.id, + serialize_meta(&node.meta), + node.content.clone(), + node.children.clone(), + node.collapsed, + node.stricken, + node.hide_stricken, + node.parent_id, + node.free_text.clone(), + ) +} + +pub fn deserialize_screen(data: Vec) -> Result { + let screen_json: JsonScreen = serde_json::from_slice(&data)?; + let mut screen = Screen::default(); + screen.max_id = screen_json.1; + + screen.nodes = screen_json.0 + .iter() + .map(|node_json| { + let node = deserialize_node(node_json); + screen.tag_db.reindex(node.id, node.content.clone()); + (node.id, node) + }) + .collect(); + + screen.arrows = screen_json.2 + .iter() + .map(|&(from, to)| (from, to, crate::random_fg_color())) + .collect(); + + Ok(screen) +} + +fn deserialize_meta(meta_json: &JsonMeta) -> Meta { + Meta { + ctime: meta_json.ctime, + mtime: meta_json.mtime, + finish_time: meta_json.finish_time, + due: meta_json.due, + tags: meta_json + .tags + .iter() + .map(|(k, v)| (k.clone(), v.clone())) + .collect(), + } +} + +fn deserialize_node(node_json: &JsonNode) -> Node { + Node { + id: node_json.0, + meta: deserialize_meta(&node_json.1), + content: node_json.2.clone(), + children: node_json.3.clone(), + collapsed: node_json.4, + stricken: node_json.5, + hide_stricken: node_json.6, + parent_id: node_json.7, + free_text: node_json.8.clone(), + rooted_coords: (1, 2), + selected: false, + color: crate::random_fg_color(), + auto_arrange: true, + } +} \ No newline at end of file diff --git a/src/lib.rs b/src/lib.rs index b58d68b..8fd1902 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,10 +13,9 @@ mod logging; mod meta; mod node; mod pack; -mod pb; mod plot; mod screen; -mod serialization; +mod json_storage; mod tagdb; mod task; @@ -37,7 +36,7 @@ pub use crate::{ node::Node, pack::Pack, screen::Screen, - serialization::{deserialize_screen, serialize_screen}, + json_storage::{deserialize_screen, serialize_screen}, tagdb::TagDB, }; @@ -45,7 +44,7 @@ pub type Coords = (u16, u16); pub type NodeID = u64; pub type ScreenDesc = (HashMap, HashMap); -#[derive(Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Dir { L, R, diff --git a/src/screen.rs b/src/screen.rs index a25d15a..4a3eb75 100644 --- a/src/screen.rs +++ b/src/screen.rs @@ -23,14 +23,14 @@ use regex::Regex; use unicode_segmentation::UnicodeSegmentation; use crate::{ - cost, dateparse, distances, logging, now, plot, random_fg_color, re_matches, serialization, + cost, dateparse, distances, logging, now, plot, random_fg_color, re_matches, Action, Config, Coords, Dir, Node, NodeID, Pack, TagDB, }; pub struct Screen { pub max_id: u64, pub nodes: HashMap, - pub arrows: Vec<(NodeID, NodeID)>, + pub arrows: Vec<(NodeID, NodeID, String)>, pub work_path: Option, pub autosave_every: usize, pub config: Config, @@ -849,7 +849,7 @@ impl Screen { if let Some(node) = self.nodes.remove(&node_id) { // clean up any arrow state self.arrows - .retain(|&(ref from, ref to)| from != &node_id && to != &node_id); + .retain(|&(ref from, ref to, _)| from != &node_id && to != &node_id); // remove from tag_db self.tag_db.remove(node_id); @@ -924,6 +924,12 @@ impl Screen { pub fn run(&mut self) { self.start_raw_mode(); self.dims = terminal_size().unwrap(); + // Ensure initial layout is correct + self.arrange(); + // Select first node by default + if let Some(first_node) = self.with_node(self.drawing_root, |n| n.children.get(0).cloned()).unwrap() { + self.select_node(first_node); + } self.draw(); let stdin = stdin(); for (num_events, c) in stdin.events().enumerate() { @@ -1625,7 +1631,7 @@ impl Screen { debug!("testing that all arrows are existing nodes"); // no arrows that don't exist - for &(ref a, ref b) in &self.arrows { + for &(ref a, ref b, _) in &self.arrows { assert!(self.nodes.get(a).is_some()); assert!(self.nodes.get(b).is_some()); } @@ -1634,7 +1640,7 @@ impl Screen { pub fn save(&self) { trace!("save()"); self.assert_node_consistency(); - let data = serialization::serialize_screen(self); + let data = crate::json_storage::serialize_screen(self); if let Some(ref path) = self.work_path { let mut tmp_path = path.clone(); tmp_path.push_str(".tmp"); @@ -1673,10 +1679,10 @@ impl Screen { return; } let from = self.drawing_arrow.take().unwrap(); - if let Some(arrow) = self.selected.map(|to| (from, to)) { - let (from, to) = arrow; + if let Some(arrow) = self.selected.map(|to| (from, to, random_fg_color())) { + let (from, to, _) = arrow; if self.nodes.get(&from).is_some() && self.nodes.get(&to).is_some() { - let contains = self.arrows.iter().fold(false, |acc, &(ref nl1, ref nl2)| { + let contains = self.arrows.iter().fold(false, |acc, &(ref nl1, ref nl2, _)| { if nl1 == &from && nl2 == &to { true } else { @@ -1764,9 +1770,9 @@ impl Screen { } // print arrows - for &(ref from, ref to) in &self.arrows { + for &(ref from, ref to, ref color) in &self.arrows { let (path, (direction1, direction2)) = self.path_between_nodes(*from, *to); - self.draw_path(path, direction1, direction2); + self.draw_path(path, direction1, direction2, color); } // conditionally print drag dest arrow @@ -1778,11 +1784,11 @@ impl Screen { if let Some(to_node) = self.lookup(to) { let (path, (direction1, direction2)) = self.path_between_nodes(*from_node, *to_node); - self.draw_path(path, direction1, direction2); + self.draw_path(path, direction1, direction2, &random_fg_color()); } else { let (path, (direction1, direction2)) = self.path_from_node_to_point(*from_node, to); - self.draw_path(path, direction1, direction2); + self.draw_path(path, direction1, direction2, &random_fg_color()); } } else { warn!("dragging_from set, but NOT dragging_to"); @@ -1992,13 +1998,13 @@ impl Screen { drawn } - fn draw_path(&self, internal_path: Vec, start_dir: Dir, dest_dir: Dir) { + fn draw_path(&self, internal_path: Vec, start_dir: Dir, dest_dir: Dir, color: &str) { let path: Vec<_> = internal_path .iter() .filter_map(|&c| self.internal_to_screen_xy(c)) .collect(); trace!("draw_path({:?}, {:?}, {:?})", path, start_dir, dest_dir); - print!("{}", random_fg_color()); + print!("{}", color); if path.len() == 1 { print!("{} ↺", cursor::Goto(path[0].0, path[0].1)) } else if path.len() > 1 { @@ -2077,7 +2083,6 @@ impl Screen { } fn path_from_node_to_point(&self, start: NodeID, to: Coords) -> (Vec, (Dir, Dir)) { - // TODO this is mostly copypasta from path_between_nodes, DRY trace!("getting path between node {} and point {:?}", start, to); let startbounds = self.bounds_for_lookup(start); if startbounds.is_none() { @@ -2085,17 +2090,11 @@ impl Screen { return (vec![], (Dir::R, Dir::R)); } let (s1, s2) = startbounds.unwrap(); - let init = (self.path(s2, to), (Dir::R, Dir::R)); - let paths = vec![(self.path(s1, to), (Dir::L, Dir::R))]; - paths - .into_iter() - .fold(init, |(spath, sdirs), (path, dirs)| { - if path.len() < spath.len() { - (path, dirs) - } else { - (spath, sdirs) - } - }) + + self.path_with_directions( + &[(s1, Dir::L), (s2, Dir::R)], + &[(to, Dir::R)], + ) } fn path_between_nodes(&self, start: NodeID, to: NodeID) -> (Vec, (Dir, Dir)) { @@ -2109,28 +2108,44 @@ impl Screen { let (s1, s2) = startbounds.unwrap(); let (t1, t2) = tobounds.unwrap(); - let init = (self.path(s2, t2), (Dir::R, Dir::R)); - let paths = vec![ - (self.path(s1, t2), (Dir::L, Dir::R)), - (self.path(s2, t1), (Dir::R, Dir::L)), - (self.path(s1, t1), (Dir::L, Dir::L)), - ]; - paths - .into_iter() - .fold(init, |(spath, sdirs), (path, dirs)| { - if path.len() < spath.len() { - (path, dirs) - } else { - (spath, sdirs) - } - }) + self.path_with_directions( + &[(s1, Dir::L), (s2, Dir::R)], + &[(t1, Dir::L), (t2, Dir::R)], + ) + } + + fn path_with_directions( + &self, + starts: &[(Coords, Dir)], + dests: &[(Coords, Dir)] + ) -> (Vec, (Dir, Dir)) { + let dests_no_dir: Vec = dests.iter() + .map(|(coords, _)| *coords) + .collect(); + let path = self.path(starts, &dests_no_dir); + let last_node = if let Some(n) = path.last() { n } else { + return (path, (Dir::L, Dir::R)); + }; + let first_node = path.first().unwrap(); + let last_dir = dests.iter() + .filter(|(dest, _)| dest == last_node) + .map(|(_, dir)| dir) + .next() + .unwrap(); + let first_dir = starts.iter() + .filter(|(start, _)| start == first_node) + .map(|(_, dir)| dir) + .next() + .unwrap(); + + (path, (*first_dir, *last_dir)) } - fn path(&self, start: Coords, dest: Coords) -> Vec { + fn path(&self, starts: &[(Coords, Dir)], dests: &[Coords]) -> Vec { trace!( "path({:?}, {:?} (screen size: {} x {})", - start, - dest, + starts, + dests, self.dims.0, self.dims.1 ); @@ -2143,27 +2158,81 @@ impl Screen { (c.0, max(c.1, 2) - 1), ] } + let heuristic = |from: Coords| + dests.iter() + .map(|dest| cost(from, *dest)) + .min() + .unwrap_or(std::u16::MAX); // maps from location to previous location - let mut visited: HashMap = HashMap::new(); - let mut pq = BinaryHeap::new(); + let mut visited: HashMap = HashMap::new(); + + // priority queue of nodes to explore, initially populated w/ starting locs + // tuple is (priority, coords, last_direction, cost) + let mut pq: BinaryHeap<_> = starts.into_iter() + .map(|(point, dir)| ( + std::u16::MAX - heuristic(*point), + *point, + match dir { + Dir::L => -1, + Dir::R => 1, + }, + 0 + )) + .collect(); - let mut cursor = start; + let (_, mut cursor, mut cursor_last_direction, mut cursor_cost) = + pq.pop().expect("path() called without any starting point"); trace!("starting draw"); - while cursor != dest { + while !dests.contains(&cursor) { for neighbor in perms(cursor) { + // direction is -2, -1, 1, or 2 + let direction = (neighbor.0 as i32) - (cursor.0 as i32) + + 2 * ((neighbor.1 as i32) - (cursor.1 as i32)); + + let move_cost = if cursor_last_direction == direction { + 1 // We're moving in the same direction as before: free + } else { + 2 // We changed direction, which is discouraged to arrows simple + }; + + let turn_into_dest_cost = if + (direction == -2 || direction == 2) && + heuristic(neighbor) == 0 + { + // When we arrive at dest, it's good to be traveling in the direction + // that the carrot will be pointing. e.g. + // + // Bad: ──┐ Good:─┐ + // │ │ + // >dest └─>dest + 5 + } else { + 0 + }; + + // Total cost to get to this point + let total_cost = move_cost + turn_into_dest_cost + cursor_cost; + if (neighbor.0 < self.dims.0 && neighbor.1 < self.dims.1 + self.view_y && !self.occupied(neighbor) - || neighbor == dest) - && !visited.contains_key(&neighbor) + || dests.contains(&neighbor)) + && visited.get(&neighbor) // Only if we found... + .map(|(_, old_cost)| *old_cost > total_cost) // a cheaper route... + .unwrap_or(true) // or the first route { - let c = std::u16::MAX - cost(neighbor, dest); - pq.push((c, neighbor)); - visited.insert(neighbor, cursor); + let priority = std::u16::MAX + - heuristic(neighbor) + - total_cost; + + pq.push((priority, neighbor, direction, total_cost)); + visited.insert(neighbor, (cursor, total_cost)); } } - if let Some((_, coords)) = pq.pop() { + if let Some((_, coords, last_direction, cost)) = pq.pop() { cursor = coords; + cursor_cost = cost; + cursor_last_direction = last_direction; } else { trace!("no path, possible node overlap"); return vec![]; @@ -2173,10 +2242,10 @@ impl Screen { } trace!("done draw, starting backtrack"); - let mut back_cursor = dest; - let mut path = vec![dest]; - while back_cursor != start { - let prev = visited[&back_cursor]; + let mut back_cursor = cursor; + let mut path = vec![cursor]; + while !starts.iter().any(|(start, _)| *start == back_cursor) { + let (prev, _) = visited[&back_cursor]; path.push(prev); back_cursor = prev; } diff --git a/test/newdb.json b/test/newdb.json new file mode 100644 index 0000000..0c00620 --- /dev/null +++ b/test/newdb.json @@ -0,0 +1 @@ +[[[6,{"ctime":1735781417,"mtime":1735781421,"finish_time":null,"tags":[],"due":null},"another",[7],false,false,false,0,null],[3,{"ctime":1735780134,"mtime":1735780137,"finish_time":null,"tags":[],"due":null},"bundle",[4,5],false,false,false,0,null],[7,{"ctime":1735781421,"mtime":1735781423,"finish_time":null,"tags":[],"due":null},"good",[],false,false,false,6,null],[4,{"ctime":1735780137,"mtime":1735780139,"finish_time":null,"tags":[],"due":null},"vite",[],false,false,false,3,null],[5,{"ctime":1735780139,"mtime":1735780142,"finish_time":null,"tags":[],"due":null},"esbuild",[],false,false,false,3,null],[2,{"ctime":1735780131,"mtime":1735780133,"finish_time":null,"tags":[],"due":null},"outtt",[],false,false,false,1,null],[1,{"ctime":1735780130,"mtime":1735780131,"finish_time":null,"tags":[],"due":null},"fresh",[2],false,false,false,0,null],[0,{"ctime":1735780128,"mtime":1735780128,"finish_time":null,"tags":[],"due":null},"home",[1,3,6],false,false,false,0,null]],8,[]] \ No newline at end of file diff --git a/test/newdb_2.json b/test/newdb_2.json new file mode 100644 index 0000000..961dd47 --- /dev/null +++ b/test/newdb_2.json @@ -0,0 +1 @@ +[[[7,{"ctime":1735781781,"mtime":1735781783,"finish_time":null,"tags":[],"due":null},"anotherhere",[8,12],false,false,false,0,null],[9,{"ctime":1735781786,"mtime":1735781788,"finish_time":null,"tags":[],"due":null},"c2",[10],false,false,false,8,null],[3,{"ctime":1735781771,"mtime":1735781774,"finish_time":null,"tags":[],"due":null},"zchile",[],false,false,false,1,null],[2,{"ctime":1735781767,"mtime":1735781770,"finish_time":null,"tags":[],"due":null},"fchile",[],false,false,false,1,null],[5,{"ctime":1735781778,"mtime":1735781780,"finish_time":null,"tags":[],"due":null},"onemore",[],false,false,false,4,null],[8,{"ctime":1735781784,"mtime":1735781786,"finish_time":null,"tags":[],"due":null},"c1",[9,15],false,false,false,7,null],[4,{"ctime":1735781775,"mtime":1735781777,"finish_time":null,"tags":[],"due":null},"andlater",[5],false,false,false,0,null],[1,{"ctime":1735781765,"mtime":1735781766,"finish_time":null,"tags":[],"due":null},"myfirs",[2,3],false,false,false,0,null],[0,{"ctime":1735781762,"mtime":1735781762,"finish_time":null,"tags":[],"due":null},"home",[1,4,7],false,false,false,0,null],[10,{"ctime":1735781789,"mtime":1735781790,"finish_time":null,"tags":[],"due":null},"c3",[],false,false,false,9,null],[12,{"ctime":1735781792,"mtime":1735781794,"finish_time":null,"tags":[],"due":null},"heyyyy",[],false,false,false,7,null],[15,{"ctime":1735784562,"mtime":1735784565,"finish_time":null,"tags":[],"due":null},"1",[],false,false,false,8,null]],16,[]] \ No newline at end of file diff --git a/test/newdb_3.json b/test/newdb_3.json new file mode 100644 index 0000000..8b5484f --- /dev/null +++ b/test/newdb_3.json @@ -0,0 +1,61 @@ +[ + [ + [ + 2, + { "ctime": 1735784943, "mtime": 1735784944, "finish_time": null, "tags": [], "due": null }, + "baa", + [], + false, + false, + false, + 1, + null + ], + [ + 3, + { "ctime": 1735784945, "mtime": 1735784947, "finish_time": null, "tags": [], "due": null }, + "iiii", + [5], + false, + false, + false, + 0, + null + ], + [ + 5, + { "ctime": 1735784947, "mtime": 1735784950, "finish_time": null, "tags": [], "due": null }, + "z", + [], + false, + false, + false, + 3, + null + ], + [ + 1, + { "ctime": 1735784942, "mtime": 1735784942, "finish_time": null, "tags": [], "due": null }, + "fii", + [2], + false, + false, + false, + 0, + null + ], + [ + 0, + { "ctime": 1735784940, "mtime": 1735784940, "finish_time": null, "tags": [], "due": null }, + "home", + [1, 3], + false, + false, + false, + 0, + null + ] + ], + 5, + [] +] diff --git a/test/tail.void.json b/test/tail.void.json new file mode 100644 index 0000000..ae3a18b --- /dev/null +++ b/test/tail.void.json @@ -0,0 +1 @@ +[[[12,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"im3",[],false,false,false,8,null],[6,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"notherone",[7,8],false,false,false,0,null],[4,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"ohola",[5],false,false,false,1,null],[1,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"firstuo",[2,3,4],false,false,false,0,null],[3,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"dos",[],false,false,false,1,null],[8,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"imok",[9,11,12],false,false,false,6,null],[7,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"heyyy",[],false,false,false,6,null],[11,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"ohola",[],false,false,false,8,null],[0,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"home",[1,6],false,false,false,0,null],[5,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"heyy",[],false,false,false,4,null],[2,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"bar",[],false,false,false,1,null],[9,{"ctime":0,"mtime":0,"finish_time":null,"tags":[],"due":null},"hnot",[],false,false,false,8,null]],12,[]] \ No newline at end of file