note-state 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
note_state/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """note-state — a portable state-tracking layer for markdown note vaults."""
2
+
3
+ __version__ = "0.1.0"
note_state/cli.py ADDED
@@ -0,0 +1,251 @@
1
+ """
2
+ note_state.cli — a portable state-tracking layer for markdown-based note apps.
3
+
4
+ Works with any note application that stores notes as markdown files with
5
+ YAML frontmatter on local disk: Obsidian, Dendron, Foam, plain markdown
6
+ vaults, and similar. (See the project README for notes on Logseq/Notion,
7
+ which store notes a little differently.)
8
+
9
+ It does one job: read/write a `state` field (plus a `state_history` log)
10
+ in each note's frontmatter, and enforce the transition rules defined in
11
+ states.yaml so a note can't silently skip stages of the pipeline.
12
+
13
+ Commands:
14
+ note-state init <file_or_dir>
15
+ note-state set <file> <state> [--force] [--note "reason"]
16
+ note-state list [dir] [--state STATE]
17
+ note-state report [dir]
18
+ note-state graph
19
+ """
20
+
21
+ import argparse
22
+ import datetime
23
+ import importlib.resources
24
+ import os
25
+ import pathlib
26
+ import sys
27
+
28
+ import yaml
29
+
30
+ from . import __version__
31
+
32
+ DEFAULT_CONFIG_NAMES = ["states.yaml", ".note-states.yaml"]
33
+ FRONTMATTER_DELIM = "---"
34
+
35
+
36
+ # ---------------------------------------------------------------- config --
37
+
38
+ def find_local_config():
39
+ """Look for states.yaml in the current directory or any parent."""
40
+ d = pathlib.Path.cwd()
41
+ while True:
42
+ for name in DEFAULT_CONFIG_NAMES:
43
+ p = d / name
44
+ if p.exists():
45
+ return str(p)
46
+ if d.parent == d:
47
+ return None
48
+ d = d.parent
49
+
50
+
51
+ def load_config(path=None):
52
+ if path:
53
+ with open(path, "r", encoding="utf-8") as f:
54
+ cfg = yaml.safe_load(f)
55
+ else:
56
+ local = find_local_config()
57
+ if local:
58
+ with open(local, "r", encoding="utf-8") as f:
59
+ cfg = yaml.safe_load(f)
60
+ else:
61
+ # Fall back to the bundled default pipeline.
62
+ data = importlib.resources.files("note_state").joinpath("default_states.yaml")
63
+ cfg = yaml.safe_load(data.read_text(encoding="utf-8"))
64
+
65
+ if "states" not in cfg or "transitions" not in cfg or "initial_state" not in cfg:
66
+ sys.exit("Config is missing required keys: states, transitions, initial_state")
67
+ return cfg
68
+
69
+
70
+ # ------------------------------------------------------------- frontmatter --
71
+
72
+ def read_note(path):
73
+ """Return (meta_dict, body_text) for a markdown file, tolerating no frontmatter."""
74
+ text = pathlib.Path(path).read_text(encoding="utf-8")
75
+ if text.startswith(FRONTMATTER_DELIM):
76
+ parts = text.split(FRONTMATTER_DELIM, 2)
77
+ if len(parts) == 3:
78
+ meta = yaml.safe_load(parts[1]) or {}
79
+ body = parts[2].lstrip("\n")
80
+ return meta, body
81
+ return {}, text
82
+
83
+
84
+ def write_note(path, meta, body):
85
+ yaml_str = yaml.safe_dump(
86
+ meta, sort_keys=False, allow_unicode=True, default_flow_style=False
87
+ )
88
+ content = f"{FRONTMATTER_DELIM}\n{yaml_str}{FRONTMATTER_DELIM}\n\n{body}"
89
+ pathlib.Path(path).write_text(content, encoding="utf-8")
90
+
91
+
92
+ def iter_md_files(root):
93
+ if os.path.isfile(root):
94
+ yield root
95
+ return
96
+ for dirpath, _, filenames in os.walk(root):
97
+ for fn in filenames:
98
+ if fn.endswith(".md"):
99
+ yield os.path.join(dirpath, fn)
100
+
101
+
102
+ def today_iso():
103
+ return datetime.date.today().isoformat()
104
+
105
+
106
+ # -------------------------------------------------------------- commands --
107
+
108
+ def cmd_init(args, cfg):
109
+ initial = cfg["initial_state"]
110
+ touched = 0
111
+ for path in iter_md_files(args.target):
112
+ meta, body = read_note(path)
113
+ if "state" in meta:
114
+ print(f"skip {path} (already: {meta['state']})")
115
+ continue
116
+ meta["state"] = initial
117
+ meta["state_history"] = [{"state": initial, "date": today_iso()}]
118
+ write_note(path, meta, body)
119
+ print(f"init {path} -> {initial}")
120
+ touched += 1
121
+ print(f"\n{touched} note(s) initialized.")
122
+
123
+
124
+ def cmd_set(args, cfg):
125
+ path = args.file
126
+ meta, body = read_note(path)
127
+ current = meta.get("state", cfg["initial_state"])
128
+ target = args.state
129
+
130
+ if target not in cfg["states"]:
131
+ valid = ", ".join(cfg["states"].keys())
132
+ sys.exit(f"Unknown state '{target}'. Valid states: {valid}")
133
+
134
+ allowed = cfg["transitions"].get(current, [])
135
+ if target not in allowed and not args.force:
136
+ allowed_str = ", ".join(allowed) if allowed else "(none — terminal state)"
137
+ sys.exit(
138
+ f"Can't move '{path}' from '{current}' to '{target}'.\n"
139
+ f"Allowed next states from '{current}': {allowed_str}\n"
140
+ f"Use --force to override anyway."
141
+ )
142
+
143
+ meta["state"] = target
144
+ history = meta.setdefault("state_history", [])
145
+ entry = {"state": target, "date": today_iso()}
146
+ if args.note:
147
+ entry["note"] = args.note
148
+ history.append(entry)
149
+ write_note(path, meta, body)
150
+ print(f"{path}: {current} -> {target}")
151
+
152
+
153
+ def cmd_list(args, cfg):
154
+ rows = []
155
+ for path in iter_md_files(args.target):
156
+ meta, _ = read_note(path)
157
+ state = meta.get("state", "(none)")
158
+ if args.state and state != args.state:
159
+ continue
160
+ history = meta.get("state_history", [])
161
+ last_date = history[-1]["date"] if history else "?"
162
+ rows.append((state, last_date, path))
163
+
164
+ if not rows:
165
+ print("No notes found.")
166
+ return
167
+
168
+ rows.sort(key=lambda r: (r[0], r[2]))
169
+ width = max(len(r[0]) for r in rows)
170
+ for state, last_date, path in rows:
171
+ print(f"{state.ljust(width)} {last_date} {path}")
172
+
173
+
174
+ def cmd_report(args, cfg):
175
+ counts = {}
176
+ aged = []
177
+ today = datetime.date.today()
178
+
179
+ for path in iter_md_files(args.target):
180
+ meta, _ = read_note(path)
181
+ state = meta.get("state", "(none)")
182
+ counts[state] = counts.get(state, 0) + 1
183
+ history = meta.get("state_history", [])
184
+ if history:
185
+ try:
186
+ last = datetime.date.fromisoformat(history[-1]["date"])
187
+ aged.append(((today - last).days, state, path))
188
+ except ValueError:
189
+ pass
190
+
191
+ print("Notes per state:")
192
+ for state, n in sorted(counts.items(), key=lambda kv: -kv[1]):
193
+ print(f" {state.ljust(16)} {n}")
194
+
195
+ if aged:
196
+ aged.sort(reverse=True)
197
+ print("\nOldest since last transition (possible bottlenecks):")
198
+ for age, state, path in aged[:10]:
199
+ print(f" {age:>4}d {state.ljust(16)} {path}")
200
+
201
+
202
+ def cmd_graph(args, cfg):
203
+ print("stateDiagram-v2")
204
+ for src, dests in cfg["transitions"].items():
205
+ if not dests:
206
+ continue
207
+ for dest in dests:
208
+ print(f" {src} --> {dest}")
209
+
210
+
211
+ # ------------------------------------------------------------------ main --
212
+
213
+ def main():
214
+ parser = argparse.ArgumentParser(
215
+ prog="note-state",
216
+ description="Track note states through a knowledge-processing pipeline.",
217
+ )
218
+ parser.add_argument("--version", action="version", version=f"note-state {__version__}")
219
+ parser.add_argument("--config", help="path to states.yaml", default=None)
220
+ sub = parser.add_subparsers(dest="command", required=True)
221
+
222
+ p_init = sub.add_parser("init", help="stamp an initial state onto notes that don't have one")
223
+ p_init.add_argument("target", help="file or directory")
224
+ p_init.set_defaults(func=cmd_init)
225
+
226
+ p_set = sub.add_parser("set", help="transition a note to a new state")
227
+ p_set.add_argument("file")
228
+ p_set.add_argument("state")
229
+ p_set.add_argument("--force", action="store_true", help="allow a transition not listed in states.yaml")
230
+ p_set.add_argument("--note", default=None, help="optional short reason, stored in state_history")
231
+ p_set.set_defaults(func=cmd_set)
232
+
233
+ p_list = sub.add_parser("list", help="list notes and their current state")
234
+ p_list.add_argument("target", nargs="?", default=".")
235
+ p_list.add_argument("--state", default=None, help="filter to a single state")
236
+ p_list.set_defaults(func=cmd_list)
237
+
238
+ p_report = sub.add_parser("report", help="counts per state + oldest unmoved notes")
239
+ p_report.add_argument("target", nargs="?", default=".")
240
+ p_report.set_defaults(func=cmd_report)
241
+
242
+ p_graph = sub.add_parser("graph", help="print the state machine as a Mermaid diagram")
243
+ p_graph.set_defaults(func=cmd_graph)
244
+
245
+ args = parser.parse_args()
246
+ cfg = load_config(args.config)
247
+ args.func(args, cfg)
248
+
249
+
250
+ if __name__ == "__main__":
251
+ main()
@@ -0,0 +1,62 @@
1
+ # Bundled default state machine — used automatically when no states.yaml
2
+ # is found in the current directory or any parent. Copy this file into
3
+ # your vault as `states.yaml` (or `.note-states.yaml`) and edit freely;
4
+ # a local copy always takes priority over this bundled default.
5
+
6
+ initial_state: drafting
7
+
8
+ states:
9
+ drafting:
10
+ label: "Drafting"
11
+ description: >
12
+ Raw capture. Get the idea down with as little friction as possible —
13
+ fragments, bullet points, half-formed sentences are all fine here.
14
+ review:
15
+ label: "Review"
16
+ description: >
17
+ Check the idea for accuracy, internal consistency, and missing gaps.
18
+ Decide whether it deserves to keep moving or needs rework.
19
+ formatting:
20
+ label: "Formatting"
21
+ description: >
22
+ Give the note a shape: headings, atomic scope (one idea per note),
23
+ consistent title, clean prose, tags.
24
+ referencing:
25
+ label: "Referencing"
26
+ description: >
27
+ Attach provenance: citations, source links, backlinks to the notes
28
+ this one draws on or was extracted from.
29
+ integrating:
30
+ label: "Integrating"
31
+ description: >
32
+ Weave the note into the graph: link it from relevant Maps of
33
+ Content / index notes, cross-link related permanent notes, make it
34
+ discoverable through search and tags rather than sitting orphaned.
35
+ published:
36
+ label: "Published"
37
+ description: >
38
+ Stable and reusable. Safe to cite, export, or share outside the
39
+ vault. The "done" state of the main pipeline.
40
+ needs_revision:
41
+ label: "Needs Revision"
42
+ description: >
43
+ Flagged during any stage as having a problem (factual error,
44
+ unclear scope, broken links). Always exits back to the stage that
45
+ flagged it, once fixed.
46
+ archived:
47
+ label: "Archived"
48
+ description: >
49
+ Retired or superseded by another note. No longer part of the
50
+ active pipeline, but kept for the record.
51
+
52
+ # Map of state -> list of states it's allowed to move to.
53
+ # cmd `set` enforces this unless you pass --force.
54
+ transitions:
55
+ drafting: [review, archived]
56
+ review: [formatting, drafting, needs_revision, archived]
57
+ formatting: [referencing, review, needs_revision, archived]
58
+ referencing: [integrating, formatting, needs_revision, archived]
59
+ integrating: [published, referencing, needs_revision, archived]
60
+ published: [integrating, archived]
61
+ needs_revision: [drafting, review, formatting, referencing, integrating]
62
+ archived: []
@@ -0,0 +1,177 @@
1
+ Metadata-Version: 2.4
2
+ Name: note-state
3
+ Version: 0.1.0
4
+ Summary: A portable state-tracking layer for markdown note vaults (Obsidian, Dendron, Foam, and plain markdown folders).
5
+ Author-email: superxgen <superxgen@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/resystem0/note-state
8
+ Project-URL: Issues, https://github.com/resystem0/note-state/issues
9
+ Keywords: notes,markdown,obsidian,pkm,zettelkasten,frontmatter,state-machine
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: End Users/Desktop
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Text Processing :: Markup :: Markdown
17
+ Classifier: Topic :: Utilities
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: PyYAML>=6.0
22
+ Dynamic: license-file
23
+
24
+ # note-state
25
+
26
+ A portable "patch" for turning a pile of notes into a *pipeline*: every note
27
+ carries an explicit `state` telling you how far it's traveled from raw
28
+ capture to integrated, trustworthy knowledge — the same way a ticket in an
29
+ issue tracker carries a status, or a part on an assembly line carries a
30
+ stage.
31
+
32
+ Works with any note application that stores notes as markdown files with
33
+ **YAML frontmatter** on local disk — Obsidian, Dendron, Foam, Logseq, plain
34
+ markdown vaults, even a folder of `.md` files with no app at all.
35
+
36
+ ## Install
37
+
38
+ ```bash
39
+ pip install note-state
40
+ ```
41
+
42
+ ## The pipeline
43
+
44
+ ```
45
+ drafting → review → formatting → referencing → integrating → published
46
+ ↘ ↘ ↘ ↘
47
+ needs_revision (loops back to whichever
48
+ stage flagged the problem)
49
+
50
+ any state ──────────────────────────────→ archived
51
+ ```
52
+
53
+ | State | What happens here | Exit condition |
54
+ |---|---|---|
55
+ | **Drafting** | Raw capture. Get the idea down with as little friction as possible. | The idea has enough substance to be judged. |
56
+ | **Review** | Read it critically: accurate, internally consistent, complete? | Content is verified, or it's sent back to Drafting. |
57
+ | **Formatting** | Give it shape: headings, atomic scope, a real title, clean prose, tags. | It reads well and is scoped correctly. |
58
+ | **Referencing** | Attach provenance: citations, source links, backlinks. | Every non-obvious claim is traceable to a source. |
59
+ | **Integrating** | Weave it into the graph: link from a Map of Content, cross-link related notes. | It's reachable from at least one other place in the vault. |
60
+ | **Published** | Stable and reusable — safe to cite, export, or share. | Terminal, but can be pulled back into Integrating. |
61
+ | **Needs Revision** *(utility state)* | Something's wrong. Flagged from any stage. | Always exits back to the stage that flagged it. |
62
+ | **Archived** *(utility state)* | Retired or superseded. | Terminal. |
63
+
64
+ This isn't sacred — it's just the bundled default. Drop your own
65
+ `states.yaml` next to your notes (or in any parent directory) to define a
66
+ different pipeline; `note-state` doesn't hardcode the states, it just
67
+ enforces whatever pipeline you describe.
68
+
69
+ ## The metadata schema
70
+
71
+ Every tracked note gets two frontmatter fields: the current state, and a
72
+ timestamped log of how it got there.
73
+
74
+ ```yaml
75
+ ---
76
+ title: "Maps of Content connect clusters of related notes"
77
+ state: referencing
78
+ state_history:
79
+ - state: drafting
80
+ date: 2026-07-01
81
+ - state: review
82
+ date: 2026-07-05
83
+ - state: formatting
84
+ date: 2026-07-08
85
+ - state: referencing
86
+ date: 2026-07-12
87
+ note: "added three sources"
88
+ tags: [pkm, moc]
89
+ ---
90
+
91
+ Note body goes here as normal — nothing about the state system touches
92
+ the content itself.
93
+ ```
94
+
95
+ `state_history` is what makes the system useful beyond a label: it's an
96
+ audit trail you can query later ("which notes have been stuck in Review for
97
+ three weeks?", "how long does formatting usually take?").
98
+
99
+ ## Usage
100
+
101
+ ```bash
102
+ # Stamp every note in a folder with the initial state (only touches
103
+ # notes that don't already have one — safe to run repeatedly)
104
+ note-state init ./my-vault
105
+
106
+ # Move a note forward
107
+ note-state set my-vault/atomic-notes.md review
108
+
109
+ # Try to skip a stage — this is rejected unless you pass --force
110
+ note-state set my-vault/atomic-notes.md published
111
+
112
+ # See what's where
113
+ note-state list ./my-vault
114
+ note-state list ./my-vault --state review
115
+
116
+ # Find bottlenecks — counts per state + oldest untouched notes
117
+ note-state report ./my-vault
118
+
119
+ # Print the pipeline as a Mermaid diagram (paste into anything that renders it)
120
+ note-state graph
121
+ ```
122
+
123
+ ## Customizing the pipeline
124
+
125
+ Two files define the state machine:
126
+
127
+ - **`states.yaml`** — states + allowed transitions. `note-state` looks for
128
+ this file (or `.note-states.yaml`) in the current directory, then any
129
+ parent directory, and falls back to the bundled default pipeline above if
130
+ none is found. Copy the bundled default as a starting point and edit
131
+ freely — rename stages, add a `translating` or `peer-review` state,
132
+ rewire transitions.
133
+ - Pass `--config path/to/states.yaml` to point at a specific file instead
134
+ of relying on directory discovery.
135
+
136
+ ## Wiring it into a specific app
137
+
138
+ - **Obsidian, Dendron, Foam, plain markdown folders** — these already read
139
+ and preserve YAML frontmatter, so `note-state` works with zero
140
+ adaptation: run it straight against your vault folder. In Obsidian, the
141
+ [Dataview](https://github.com/blacksmithgu/obsidian-dataview) plugin can
142
+ turn `state` into a live board, e.g.:
143
+
144
+ ```
145
+ TABLE state, state_history[-1].date AS "last moved"
146
+ FROM "Notes"
147
+ SORT state
148
+ ```
149
+
150
+ - **Logseq** — pages are markdown files too, but Logseq's own metadata
151
+ lives as a `key:: value` line on the first block rather than a `---`
152
+ frontmatter block. Keep frontmatter *and* Logseq properties side by side
153
+ (Logseq ignores the frontmatter block, `note-state` ignores the `::`
154
+ line), or adapt the parser in `note_state/cli.py` to read
155
+ `state:: drafting` instead.
156
+
157
+ - **Notion** (or any database-backed app without local files) — recreate
158
+ `states.yaml` as a **Status** property with matching options, and use a
159
+ Board view grouped by it. You lose `note-state`'s enforcement of
160
+ transition rules unless you script it against the Notion API, but the
161
+ state vocabulary and pipeline shape carry over directly.
162
+
163
+ ## Design notes
164
+
165
+ - **Backward moves are allowed on purpose.** Review can send a note back to
166
+ Drafting; Formatting can send it back to Review. Knowledge work isn't
167
+ strictly linear, and a state system that can't represent "this needs
168
+ another pass" will just get ignored.
169
+ - **`needs_revision` is a trapdoor, not a stage.** It exists so a problem
170
+ found at any point can be flagged without losing the note's place in the
171
+ pipeline — it always returns to wherever it came from.
172
+ - **`--force` exists deliberately.** Rules are useful defaults, not a cage;
173
+ sometimes you really do want to fast-track or bulk-correct a note.
174
+
175
+ ## License
176
+
177
+ MIT
@@ -0,0 +1,9 @@
1
+ note_state/__init__.py,sha256=EfgXMRAmWn2fjHw_V1DnoVMAnPKo6-Zm-Zfcamsx3Vw,102
2
+ note_state/cli.py,sha256=ogm3ao6RmzurMxxcnU3trmVzSRZKcZm5VYK-nntTaPo,8362
3
+ note_state/default_states.yaml,sha256=M755AD8e2OCGZTAKLBxcZytD2f7m-mGewsyZwQckEAk,2489
4
+ note_state-0.1.0.dist-info/licenses/LICENSE,sha256=kly0zl_87o8kF1kD2kW0EJIqFOirFiZPYRw5X3BOXkQ,1066
5
+ note_state-0.1.0.dist-info/METADATA,sha256=ArYtmw15IgoCZH10Tbga02WEOjhnqcp4x-QeEuuxgNI,7137
6
+ note_state-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
7
+ note_state-0.1.0.dist-info/entry_points.txt,sha256=p_oMzKEtUQsiXkyhYEB7Nbop3QbQ4JYOYTW1pLUFUIM,51
8
+ note_state-0.1.0.dist-info/top_level.txt,sha256=rN_hy_ZhAEaNiK62LQwFhAOBEsEO_eQZm_tKwoAxzZU,11
9
+ note_state-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ note-state = note_state.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 resystem0
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ note_state