padzapp 1.5.0

An ergonomic, context-aware scratch pad library with plain text storage
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
//! Inline metadata for md/lex files.
//!
//! Two dialects, both carrying the same key set as the JSON archive's
//! `metadata` object:
//!
//! ## Markdown — YAML frontmatter (dotted keys under `padz.`)
//!
//! ```markdown
//! ---
//! padz.schema_version: 1
//! padz.id: "aaaa-..."
//! padz.created_at: "2026-04-22T00:00:00Z"
//! padz.status: Planned
//! padz.tags:
//!   - work
//! ---
//!
//! Title
//!
//! body...
//! ```
//!
//! ## Lex — top-of-document annotations
//!
//! ```lex
//! :: padz.schema_version :: 1
//! :: padz.id :: aaaa-...
//! :: padz.status :: Planned
//! :: padz.tags :: work,personal
//!
//! Title
//!
//!     body
//! ```
//!
//! ## Import detection
//!
//! - md: starts with a `---` line (YAML frontmatter fence) → parse until
//!   the closing `---`
//! - lex: starts with one or more `:: padz.KEY :: VALUE` lines → parse
//!   consecutive leading annotations
//!
//! Files without the opening sentinel are imported as plain content (no
//! metadata), preserving backwards compatibility.
//!
//! ## Implementation
//!
//! Markdown frontmatter uses `serde_yaml` for both emit and parse — the YAML
//! surface is full-featured enough that hand-rolling is a foot-gun. Lex
//! annotations are hand-parsed because the syntax is custom to lex and no
//! published parser is (yet) a dependency here.

use crate::commands::metadata_schema::SCHEMA_VERSION;
use crate::model::{Metadata, TodoStatus};
use crate::store::Bucket;
use chrono::SecondsFormat;
use serde_json::{Map, Value};

pub const PADZ_PREFIX: &str = "padz.";

/// Serialize a pad's metadata as YAML frontmatter. Returns the full
/// `---\n...\n---\n\n` block, ready to prepend to the pad content.
pub fn serialize_md_frontmatter(meta: &Metadata, bucket: Bucket) -> String {
    let mapping = metadata_to_yaml_mapping(meta, bucket);
    let body = serde_yaml::to_string(&mapping)
        .expect("serializing fixed-schema metadata to YAML cannot fail");
    format!("---\n{}---\n\n", body)
}

fn metadata_to_yaml_mapping(meta: &Metadata, bucket: Bucket) -> serde_yaml::Mapping {
    use serde_yaml::Value as Y;
    let mut m = serde_yaml::Mapping::new();
    let k = |name: &str| Y::String(format!("{}{}", PADZ_PREFIX, name));
    m.insert(k("schema_version"), Y::Number(SCHEMA_VERSION.into()));
    m.insert(k("id"), Y::String(meta.id.to_string()));
    m.insert(
        k("created_at"),
        Y::String(meta.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
    );
    m.insert(
        k("updated_at"),
        Y::String(meta.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
    );
    m.insert(k("is_pinned"), Y::Bool(meta.is_pinned));
    m.insert(
        k("pinned_at"),
        match meta.pinned_at {
            Some(ts) => Y::String(ts.to_rfc3339_opts(SecondsFormat::Secs, true)),
            None => Y::Null,
        },
    );
    m.insert(k("delete_protected"), Y::Bool(meta.delete_protected));
    m.insert(
        k("parent_id"),
        match meta.parent_id {
            Some(p) => Y::String(p.to_string()),
            None => Y::Null,
        },
    );
    m.insert(
        k("status"),
        Y::String(todo_status_label(meta.status).into()),
    );
    m.insert(
        k("tags"),
        Y::Sequence(meta.tags.iter().cloned().map(Y::String).collect()),
    );
    m.insert(k("bucket"), Y::String(bucket_label(bucket).into()));
    m
}

/// Serialize a pad's metadata as lex annotations, followed by a blank line
/// so the document body stays valid lex.
pub fn serialize_lex_metadata(meta: &Metadata, bucket: Bucket) -> String {
    let mut out = String::new();
    out.push_str(&format!(":: padz.schema_version :: {}\n", SCHEMA_VERSION));
    out.push_str(&format!(":: padz.id :: {}\n", meta.id));
    out.push_str(&format!(
        ":: padz.created_at :: {}\n",
        meta.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)
    ));
    out.push_str(&format!(
        ":: padz.updated_at :: {}\n",
        meta.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)
    ));
    out.push_str(&format!(":: padz.is_pinned :: {}\n", meta.is_pinned));
    match meta.pinned_at {
        Some(ts) => out.push_str(&format!(
            ":: padz.pinned_at :: {}\n",
            ts.to_rfc3339_opts(SecondsFormat::Secs, true)
        )),
        None => out.push_str(":: padz.pinned_at :: null\n"),
    }
    out.push_str(&format!(
        ":: padz.delete_protected :: {}\n",
        meta.delete_protected
    ));
    match meta.parent_id {
        Some(p) => out.push_str(&format!(":: padz.parent_id :: {}\n", p)),
        None => out.push_str(":: padz.parent_id :: null\n"),
    }
    out.push_str(&format!(
        ":: padz.status :: {}\n",
        todo_status_label(meta.status)
    ));
    // Comma-separated for readability; parser tolerates surrounding spaces.
    out.push_str(&format!(":: padz.tags :: {}\n", meta.tags.join(",")));
    out.push_str(&format!(":: padz.bucket :: {}\n", bucket_label(bucket)));
    out.push('\n');
    out
}

/// Extract metadata + body from an md file. Returns `(metadata_json, body)`
/// when YAML frontmatter is detected, or `None` when it isn't (caller treats
/// as plain content).
///
/// Body has the frontmatter fence and its following blank line removed.
pub fn parse_md_frontmatter(raw: &str) -> Option<(Value, String)> {
    let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
    if !stripped.starts_with("---") {
        return None;
    }
    // First line must be exactly "---" (possibly with trailing whitespace)
    let mut lines = stripped.split_inclusive('\n');
    let first = lines.next()?;
    if first.trim_end_matches('\n').trim() != "---" {
        return None;
    }

    let mut yaml_buf = String::new();
    let mut end_found = false;
    let mut consumed = first.len();
    for line in lines {
        consumed += line.len();
        let trimmed = line.trim_end_matches('\n');
        if trimmed.trim() == "---" {
            end_found = true;
            break;
        }
        yaml_buf.push_str(line);
    }
    if !end_found {
        return None;
    }

    let body: String = stripped[consumed..].trim_start_matches('\n').to_string();

    // Malformed YAML: treat the whole document as plain content.
    let yaml_value: serde_yaml::Value = serde_yaml::from_str(&yaml_buf).ok()?;
    let yaml_map = match yaml_value {
        serde_yaml::Value::Mapping(m) => m,
        _ => return None,
    };

    let mut metadata = Map::new();
    for (key, value) in yaml_map {
        let key_str = match key {
            serde_yaml::Value::String(s) => s,
            _ => continue,
        };
        if let Some(bare) = key_str.strip_prefix(PADZ_PREFIX) {
            metadata.insert(bare.to_string(), yaml_to_json(value));
        }
    }
    if metadata.is_empty() {
        return None;
    }

    Some((Value::Object(metadata), body))
}

/// Convert a `serde_yaml::Value` into a `serde_json::Value`.
///
/// Non-string map keys are dropped (our schema never uses them); YAML tags are
/// stripped and the inner value is used.
fn yaml_to_json(v: serde_yaml::Value) -> Value {
    match v {
        serde_yaml::Value::Null => Value::Null,
        serde_yaml::Value::Bool(b) => Value::Bool(b),
        serde_yaml::Value::Number(n) => {
            if let Some(i) = n.as_i64() {
                Value::Number(i.into())
            } else if let Some(u) = n.as_u64() {
                Value::Number(u.into())
            } else if let Some(f) = n.as_f64() {
                serde_json::Number::from_f64(f)
                    .map(Value::Number)
                    .unwrap_or(Value::Null)
            } else {
                Value::Null
            }
        }
        serde_yaml::Value::String(s) => Value::String(s),
        serde_yaml::Value::Sequence(seq) => {
            Value::Array(seq.into_iter().map(yaml_to_json).collect())
        }
        serde_yaml::Value::Mapping(map) => {
            let mut out = Map::new();
            for (k, v) in map {
                if let serde_yaml::Value::String(key) = k {
                    out.insert(key, yaml_to_json(v));
                }
            }
            Value::Object(out)
        }
        serde_yaml::Value::Tagged(tagged) => yaml_to_json(tagged.value),
    }
}

/// Extract metadata + body from a lex file. Recognizes leading
/// `:: padz.KEY :: VALUE` annotations; stops at the first non-annotation
/// line (blank or otherwise).
pub fn parse_lex_metadata(raw: &str) -> Option<(Value, String)> {
    let stripped = raw.strip_prefix('\u{feff}').unwrap_or(raw);
    if !stripped.starts_with(":: padz.") {
        return None;
    }

    let mut metadata = Map::new();
    let mut consumed = 0usize;
    for line in stripped.split_inclusive('\n') {
        // Only strip the trailing newline — preserve trailing spaces so a
        // `:: key :: ` annotation with an empty value still parses.
        let no_newline = line.trim_end_matches('\n');
        if !no_newline.starts_with(":: padz.") {
            break;
        }
        let Some((key, value)) = parse_lex_annotation(no_newline) else {
            break;
        };
        if let Some(bare) = key.strip_prefix(PADZ_PREFIX) {
            metadata.insert(bare.to_string(), coerce_scalar(bare, value.trim()));
        }
        consumed += line.len();
    }
    if metadata.is_empty() {
        return None;
    }

    // Skip the single blank line that separates metadata from the document body.
    let body = stripped[consumed..].trim_start_matches('\n').to_string();

    Some((Value::Object(metadata), body))
}

/// Parse `":: KEY :: VALUE"` into `(KEY, VALUE)`. Returns None on malformed.
fn parse_lex_annotation(line: &str) -> Option<(&str, &str)> {
    let rest = line.strip_prefix(":: ")?;
    // Find the middle `::` separator after the key.
    let mid = rest.find(" :: ")?;
    let key = &rest[..mid];
    let value = &rest[mid + 4..]; // skip " :: "
                                  // Drop a trailing `::` if present (block-style annotation shorthand)
    let value = value.trim_end_matches(':').trim_end();
    Some((key, value))
}

/// Coerce a string lex/md scalar into the appropriate JSON value.
///
/// Known typed keys get typed values; everything else stays a string so that
/// `metadata_apply` can decide what to do with it.
fn coerce_scalar(key: &str, raw: &str) -> Value {
    match key {
        "schema_version" => raw
            .parse::<u64>()
            .map(|n| Value::Number(n.into()))
            .unwrap_or_else(|_| Value::String(raw.to_string())),
        "is_pinned" | "delete_protected" => match raw {
            "true" => Value::Bool(true),
            "false" => Value::Bool(false),
            _ => Value::String(raw.to_string()),
        },
        "pinned_at" | "parent_id" => {
            if raw == "null" || raw.is_empty() {
                Value::Null
            } else {
                Value::String(raw.to_string())
            }
        }
        "tags" => {
            // Comma-separated list. Empty string → empty array.
            if raw.is_empty() {
                Value::Array(Vec::new())
            } else {
                Value::Array(
                    raw.split(',')
                        .map(|t| Value::String(t.trim().to_string()))
                        .filter(|v| v.as_str().is_some_and(|s| !s.is_empty()))
                        .collect(),
                )
            }
        }
        _ => Value::String(raw.to_string()),
    }
}

fn todo_status_label(s: TodoStatus) -> &'static str {
    match s {
        TodoStatus::Planned => "Planned",
        TodoStatus::InProgress => "InProgress",
        TodoStatus::Done => "Done",
    }
}

fn bucket_label(b: Bucket) -> &'static str {
    match b {
        Bucket::Active => "Active",
        Bucket::Archived => "Archived",
        Bucket::Deleted => "Deleted",
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use chrono::{TimeZone, Utc};
    use uuid::Uuid;

    fn sample_meta() -> Metadata {
        let id = Uuid::parse_str("11111111-2222-3333-4444-555555555555").unwrap();
        let mut m = Metadata::new("Example Title".into());
        m.id = id;
        m.created_at = Utc.with_ymd_and_hms(2026, 4, 22, 10, 30, 0).unwrap();
        m.updated_at = Utc.with_ymd_and_hms(2026, 4, 22, 11, 0, 0).unwrap();
        m.is_pinned = true;
        m.pinned_at = Some(Utc.with_ymd_and_hms(2026, 4, 22, 11, 5, 0).unwrap());
        m.delete_protected = true;
        m.status = TodoStatus::Done;
        m.tags = vec!["work".into(), "rust".into()];
        m
    }

    #[test]
    fn test_serialize_md_frontmatter_roundtrip() {
        let meta = sample_meta();
        let block = serialize_md_frontmatter(&meta, Bucket::Active);
        assert!(block.starts_with("---\n"));
        assert!(block.ends_with("---\n\n"));

        let full = format!("{}Example Title\n\nBody text", block);
        let (parsed, body) = parse_md_frontmatter(&full).expect("frontmatter should parse");

        assert_eq!(body, "Example Title\n\nBody text");
        assert_eq!(parsed["id"], Value::String(meta.id.to_string()));
        assert_eq!(parsed["is_pinned"], Value::Bool(true));
        assert_eq!(parsed["status"], Value::String("Done".into()));
        assert_eq!(
            parsed["tags"],
            Value::Array(vec![
                Value::String("work".into()),
                Value::String("rust".into()),
            ])
        );
    }

    #[test]
    fn test_parse_md_frontmatter_no_fence_returns_none() {
        let raw = "No frontmatter here\n\nBody";
        assert!(parse_md_frontmatter(raw).is_none());
    }

    #[test]
    fn test_parse_md_frontmatter_unterminated_returns_none() {
        let raw = "---\npadz.id: \"abc\"\nno closing fence";
        assert!(parse_md_frontmatter(raw).is_none());
    }

    #[test]
    fn test_parse_md_frontmatter_ignores_non_padz_keys() {
        let raw = "---\nauthor: Alice\npadz.status: Done\n---\n\nTitle\n\nBody";
        let (parsed, body) = parse_md_frontmatter(raw).unwrap();
        assert_eq!(body, "Title\n\nBody");
        assert_eq!(parsed["status"], Value::String("Done".into()));
        assert!(parsed.get("author").is_none(), "non-padz keys stripped");
    }

    #[test]
    fn test_serialize_lex_metadata_roundtrip() {
        let meta = sample_meta();
        let block = serialize_lex_metadata(&meta, Bucket::Active);
        let full = format!("{}Example Title\n\n    Body", block);

        let (parsed, body) = parse_lex_metadata(&full).expect("lex metadata should parse");
        assert_eq!(body, "Example Title\n\n    Body");
        assert_eq!(parsed["id"], Value::String(meta.id.to_string()));
        assert_eq!(parsed["status"], Value::String("Done".into()));
        assert_eq!(parsed["is_pinned"], Value::Bool(true));
        assert_eq!(
            parsed["tags"],
            Value::Array(vec![
                Value::String("work".into()),
                Value::String("rust".into()),
            ])
        );
    }

    #[test]
    fn test_parse_lex_metadata_no_prefix_returns_none() {
        let raw = "Regular lex doc\n\n    body\n";
        assert!(parse_lex_metadata(raw).is_none());
    }

    #[test]
    fn test_parse_lex_metadata_empty_tags() {
        let raw = ":: padz.id :: abc\n:: padz.tags :: \n\nTitle\n";
        let (parsed, _) = parse_lex_metadata(raw).unwrap();
        assert_eq!(parsed["tags"], Value::Array(vec![]));
    }
}