~bsprague/everdo-export

ffe77dd425b6b31764fa9302156a0020d485e3c8 — Brandon Sprague 8 months ago 664d34a
Add support for notebooks, refactor

Refactors the code into reasonably sized packages, adds support for notes/notebooks, and generally does lots of other things too.
8 files changed, 716 insertions(+), 374 deletions(-)

A cmd/exporter/main.go
R main.go => everdo/everdo.go
R tmpl.go => templater/templater.go
M tmpl/item.md
M tmpl/non-project-items.md
A tmpl/note.md
A tmpl/notebook.md
M tmpl/project.md
A cmd/exporter/main.go => cmd/exporter/main.go +258 -0
@@ 0,0 1,258 @@
// Command everdo-export provides tools for parsing the Everdo JSON export, see
// the docs for the format at https://help.everdo.net/docs/advanced/json/
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"os"

	"git.sr.ht/~bsprague/everdo-export/everdo"
	"git.sr.ht/~bsprague/everdo-export/templater"
)

func main() {
	if err := run(os.Args); err != nil {
		log.Fatal(err)
	}
}

type Config struct {
	OutputDir string   `json:"output_dir"`
	Ignore    []string `json:"ignore"`
}

var defaultConfig = &Config{
	OutputDir: "out",
}

func run(args []string) error {
	if len(args) < 2 {
		return errors.New("usage: ./cmd <json export>")
	}

	cfg, err := loadConfig()
	if err != nil {
		return fmt.Errorf("failed to load config: %w", err)
	}

	ignore := make(map[string]bool)
	for _, ign := range cfg.Ignore {
		ignore[ign] = true
	}

	f, err := os.Open(args[1])
	if err != nil {
		return fmt.Errorf("failed to open everdo JSON export: %w", err)
	}
	defer f.Close()

	var exp everdo.Export
	if err := json.NewDecoder(f).Decode(&exp); err != nil {
		return fmt.Errorf("failed to decode export: %w", err)
	}

	tags := make(map[string]everdo.Tag)
	for _, tag := range exp.Tags {
		tags[tag.ID] = tag
	}

	var (
		notebooks      = make(map[string]Notebook)      // Map from Notebook ID -> Notebook
		projects       = make(map[string]Project)       // Map from Project ID -> Project
		noProjectItems = make(map[string][]everdo.Item) // Map from Item Status/List type (active, someday, etc) -> List of items with that status

		titles         = make(map[string]bool)
		recurrentTasks = make(map[string][]everdo.Item)
	)
	for _, item := range exp.Items {
		switch item.Type {
		case "p":
			p := projects[item.ID]
			p.ProjectItem = item
			projects[item.ID] = p
			titles[item.Title] = true
		case "a":
			if item.ParentID == "" {
				// Using item.List means that completed items of a type like 'Next' or 'Waiting'
				// will show up on their respective list, instead of in the 'Archived' list. This
				// seems fine.
				noProjectItems[item.List] = append(noProjectItems[item.List], item)
			} else {
				p := projects[item.ParentID]
				p.Items = append(p.Items, item)
				projects[item.ParentID] = p
			}
			if item.RecurrentTaskID != "" {
				recurrentTasks[item.RecurrentTaskID] = append(recurrentTasks[item.RecurrentTaskID], item)
			}
		case "n": // note
			if item.ParentID == "" {
				return fmt.Errorf("item %q was a note with no notebook", item.ID)
			}
			nb := notebooks[item.ParentID]
			nb.Items = append(nb.Items, item)
			notebooks[item.ParentID] = nb
		case "l": // notebook
			nb := notebooks[item.ID]
			nb.NotebookItem = item
			notebooks[item.ID] = nb
			titles[item.Title] = true
		}
	}

	for list := range noProjectItems {
		name, ok := listName(list)
		if !ok {
			return fmt.Errorf("unknown list %q", list)
		}
		titles["NPI/"+name] = true
	}

	for ig := range ignore {
		if !titles[ig] {
			return fmt.Errorf("%q was on the ignore list, but not found in the actual export", ig)
		}
	}

	tmpl, err := templater.New("tmpl/*.md", tags, recurrentTasks, cfg.OutputDir)
	if err != nil {
		return fmt.Errorf("failed to init templater: %w", err)
	}

	for id, p := range projects {
		if p.ProjectItem.ID == "" {
			// This occurs for a few early items, where they reference a parent ID that
			// isn't found. Not sure what happened here, maybe the project got archived/
			// deleted? For my case, manually inspected and safe to remove.
			delete(projects, id)
			continue
		}
		if ignore[p.ProjectItem.Title] {
			fmt.Printf("Skipping project %s\n", p.ProjectItem.Title)
			continue
		}

		if err := tmpl.WriteProject(p.ProjectItem, p.Items); err != nil {
			return fmt.Errorf("failed to write project %q: %w", p.ProjectItem.Title, err)
		}
	}

	for list, items := range noProjectItems {
		name, ok := listName(list)
		if !ok {
			return fmt.Errorf("unknown list %q", list)
		}
		if ignore["NPI/"+name] {
			fmt.Printf("Skipping non-project item list %s\n", name)
			continue
		}
		if err := tmpl.WriteNonProjectItems(name, items); err != nil {
			return fmt.Errorf("failed to write non-project items for list %q: %w", name, err)
		}
	}

	for _, nb := range notebooks {
		if ignore[nb.NotebookItem.Title] {
			fmt.Printf("Skipping notebook %s\n", nb.NotebookItem.Title)
			continue
		}

		if nb.NotebookItem.ID == "" {
			return fmt.Errorf("found %d notes attached to a non-existent notebook", len(nb.Items))
		}

		if err := tmpl.WriteNotebook(nb.NotebookItem, nb.Items); err != nil {
			return fmt.Errorf("failed to write notebook %q: %w", nb.NotebookItem.Title, err)
		}
	}

	return nil
}

type Project struct {
	ProjectItem everdo.Item
	Items       []everdo.Item
}

type Notebook struct {
	NotebookItem everdo.Item
	Items        []everdo.Item
}

func listName(typ string) (string, bool) {
	switch typ {
	case "i":
		return "Inbox", true
	case "a":
		return "Next", true
	case "m":
		return "Someday", true
	case "s":
		return "Scheduled", true
	case "w":
		return "Waiting", true
	case "d":
		return "Deleted", true
	case "r":
		return "Archived", true
	default:
		return "", false
	}
}

// Everything down here is some sanity check I was doing on the data.

func printArchivedButNotDone(items []everdo.Item) {
	// Confirming there are no items marked archived but *not* marked done.
	for _, item := range items {
		if item.Type == "a" && item.List == "r" && item.CompletedOn == nil {
			fmt.Println(item.Title, item.List)
		}
	}
}

func printCompletedStatus(items []everdo.Item) {
	for _, item := range items {
		if item.Type != "p" {
			continue
		}
		if item.CompletedOn != nil {
			fmt.Println("[Completed]", item.Title)
		} else {
			fmt.Println("[Open]", item.List, item.Title)
		}
	}
}

func printParallelSequential(items []everdo.Item) {
	for _, item := range items {
		if item.Type != "p" {
			continue
		}
		if item.NumParallelActions == nil {
			fmt.Println("[Parallel]", item.Title)
		} else {
			fmt.Println("[Sequential]", item.Title)
		}
	}
}

func loadConfig() (*Config, error) {
	f, err := os.Open("config.json")
	if errors.Is(err, fs.ErrNotExist) {
		// That's fine, use the default
		return defaultConfig, nil
	} else if err != nil {
		return nil, fmt.Errorf("failed to load config: %w", err)
	}

	var cfg *Config
	if err := json.NewDecoder(f).Decode(&cfg); err != nil {
		return nil, fmt.Errorf("malformed config: %w", err)
	}
	return cfg, nil
}

R main.go => everdo/everdo.go +1 -279
@@ 1,24 1,4 @@
// Command everdo-export provides tools for parsing the Everdo JSON export, see
// the docs for the format at https://help.everdo.net/docs/advanced/json/
package main

import (
	"encoding/json"
	"errors"
	"fmt"
	"io/fs"
	"log"
	"net/url"
	"os"
	"path/filepath"
	"text/template"
)

func main() {
	if err := run(os.Args); err != nil {
		log.Fatal(err)
	}
}
package everdo

type Export struct {
	Items     []Item     `json:"items"`


@@ 163,261 143,3 @@ type Item struct {
	PositionFocusTs      *int `json:"position_focus_ts"`       // null,
	HideNote             any  `json:"hide_note"`               // null, 0, 1, false, true
}

type Config struct {
	OutputDir string   `json:"output_dir"`
	Ignore    []string `json:"ignore"`
}

var defaultConfig = &Config{
	OutputDir: "out",
}

func run(args []string) error {
	if len(args) < 2 {
		return errors.New("usage: ./cmd <json export>")
	}

	cfg, err := loadConfig()
	if err != nil {
		return fmt.Errorf("failed to load config: %w", err)
	}

	ignore := make(map[string]bool)
	for _, ign := range cfg.Ignore {
		ignore[ign] = true
	}

	f, err := os.Open(args[1])
	if err != nil {
		return fmt.Errorf("failed to open everdo JSON export: %w", err)
	}
	defer f.Close()

	tmpl, err := template.New("").Funcs(map[string]any{
		"indent":     Indent,
		"formatNote": FormatNote,
		"label":      Label,
		"next":       Next,
		"isDeleted":  IsDeleted,
		"toTagList":  TagList,
		"toTag":      ToTag,
	}).ParseGlob("tmpl/*.md")
	if err != nil {
		return fmt.Errorf("failed to parse templates: %w", err)
	}

	var exp Export
	if err := json.NewDecoder(f).Decode(&exp); err != nil {
		return fmt.Errorf("failed to decode export: %w", err)
	}

	/*

				Okay, what's our algorithm here?

				Let's think about the desired output:

				- Each tag/area/label will get it's own page by virtue of being in a property on
		      other stuff.

				- Each project should get it's own page
				  - All TODOs are blocks on that page
					- Note: Order should be same as Everdo, split out Next / Waiting / Someday
						- Fall back to created at (earliest first?) if no explicit ordering
					- Property mapping:
						- project-type:: {sequential,parallel}
						- project-status:: {active, someday, waiting, deleted, archived}
						- [if waiting] waiting:: <waiting on>

				- All non-project 'action' items should go into one of a few pages:
					- Imported from Everdo / Next (all the 'active/next' items)
					- Imported from Everdo / Someday (all the 'someday' items)
					- Imported from Everdo / Waiting (all the 'waiting' items)
				- All notebooks should get their own page

	*/

	tags := make(map[string]Tag)
	for _, tag := range exp.Tags {
		tags[tag.ID] = tag
	}

	var (
		notebooks      = make(map[string]Notebook) // Map from Notebook ID -> Notebook
		projects       = make(map[string]Project)  // Map from Project ID -> Project
		noProjectItems = make(map[string][]Item)   // Map from Item Status/List type (active, someday, etc) -> List of items with that status
	)
	for _, item := range exp.Items {
		switch item.Type {
		case "p":
			p := projects[item.ID]
			p.ProjectItem = item
			projects[item.ID] = p
		case "a":
			if item.ParentID == "" {
				noProjectItems[item.List] = append(noProjectItems[item.List], item)
			} else {
				p := projects[item.ParentID]
				p.Items = append(p.Items, item)
				projects[item.ParentID] = p
			}
		case "n": // note
			if item.ParentID == "" {
				return fmt.Errorf("item %q was a note with no notebook", item.ID)
			}
			nb := notebooks[item.ParentID]
			nb.Items = append(nb.Items, item)
			notebooks[item.ParentID] = nb
		case "l": // notebook
			nb := notebooks[item.ID]
			nb.NotebookItem = item
			notebooks[item.ID] = nb
		}
	}

	for id, p := range projects {
		if p.ProjectItem.ID == "" {
			// This occurs for a few early items, where they reference a parent ID that
			// isn't found. Not sure what happened here, maybe the project got archived/
			// deleted? For my case, manually inspected and safe to remove.
			delete(projects, id)
			continue
		}
		if ignore[p.ProjectItem.Title] {
			fmt.Printf("Skipping project %s\n", p.ProjectItem.Title)
			continue
		}

		ptData, err := toTemplateData(p, tags)
		if err != nil {
			return fmt.Errorf("failed to generate project template data for server migration project: %w", err)
		}

		projFile, err := os.Create(filepath.Join(cfg.OutputDir, url.QueryEscape(p.ProjectItem.Title)+".md"))
		if err != nil {
			return fmt.Errorf("failed to create the project MD file: %w", err)
		}
		if err := tmpl.ExecuteTemplate(projFile, "project.md", ptData); err != nil {
			return fmt.Errorf("failed to execute template: %w", err)
		}
		if err := projFile.Close(); err != nil {
			return fmt.Errorf("failed to close project MD file: %w", err)
		}
	}

	for list, items := range noProjectItems {
		name, ok := listName(list)
		if !ok {
			return fmt.Errorf("unknown list %q", list)
		}
		listFile, err := os.Create(filepath.Join(cfg.OutputDir, url.QueryEscape("Imported from Everdo / "+name)+".md"))
		if err != nil {
			return fmt.Errorf("failed to create the non-project MD file: %w", err)
		}

		var itemData []ItemTemplateData
		for _, item := range items {
			it, err := toItemTemplateData(item, tags)
			if err != nil {
				return fmt.Errorf("failed to convert item %q: %w", item.ID, err)
			}
			itemData = append(itemData, it)
		}

		nptData := NonProjectItemsTemplateData{Items: itemData}
		if err := tmpl.ExecuteTemplate(listFile, "non-project-items.md", nptData); err != nil {
			return fmt.Errorf("failed to execute template: %w", err)
		}

		if err := listFile.Close(); err != nil {
			return fmt.Errorf("failed to close non-project MD file: %w", err)
		}
	}

	return nil
}

type Project struct {
	ProjectItem Item
	Items       []Item
}

type Notebook struct {
	NotebookItem Item
	Items        []Item
}

func listName(typ string) (string, bool) {
	switch typ {
	case "i":
		return "Inbox", true
	case "a":
		return "Next", true
	case "m":
		return "Someday", true
	case "s":
		return "Scheduled", true
	case "w":
		return "Waiting", true
	case "d":
		return "Deleted", true
	case "r":
		return "Archived", true
	default:
		return "", false
	}
}

// Everything down here is some sanity check I was doing on the data.

func printArchivedButNotDone(items []Item) {
	// Confirming there are no items marked archived but *not* marked done.
	for _, item := range items {
		if item.Type == "a" && item.List == "r" && item.CompletedOn == nil {
			fmt.Println(item.Title, item.List)
		}
	}
}

func printCompletedStatus(items []Item) {
	for _, item := range items {
		if item.Type != "p" {
			continue
		}
		if item.CompletedOn != nil {
			fmt.Println("[Completed]", item.Title)
		} else {
			fmt.Println("[Open]", item.List, item.Title)
		}
	}
}

func printParallelSequential(items []Item) {
	for _, item := range items {
		if item.Type != "p" {
			continue
		}
		if item.NumParallelActions == nil {
			fmt.Println("[Parallel]", item.Title)
		} else {
			fmt.Println("[Sequential]", item.Title)
		}
	}
}

func loadConfig() (*Config, error) {
	f, err := os.Open("config.json")
	if errors.Is(err, fs.ErrNotExist) {
		// That's fine, use the default
		return defaultConfig, nil
	} else if err != nil {
		return nil, fmt.Errorf("failed to load config: %w", err)
	}

	var cfg *Config
	if err := json.NewDecoder(f).Decode(&cfg); err != nil {
		return nil, fmt.Errorf("malformed config: %w", err)
	}
	return cfg, nil
}

R tmpl.go => templater/templater.go +415 -88
@@ 1,13 1,18 @@
package main
package templater

import (
	"errors"
	"fmt"
	"net/url"
	"os"
	"path/filepath"
	"strconv"
	"strings"
	"text/template"
	"time"
	"unicode"

	"git.sr.ht/~bsprague/everdo-export/everdo"
	"golang.org/x/exp/slices"
)



@@ 21,25 26,151 @@ var itemValue = map[string]int{
	"d": 0,
}

func scoreItem(i Item) []int {
type Templater struct {
	tmpl           *template.Template
	tags           map[string]everdo.Tag
	recurrentTasks map[string][]everdo.Item
	outDir         string
}

func New(
	globPath string,
	tags map[string]everdo.Tag,
	recurrentTasks map[string][]everdo.Item,
	outDir string,
) (*Templater, error) {
	tmpl, err := template.New("").Funcs(map[string]any{
		"indent":         Indent,
		"formatItemNote": FormatItemNote,
		"formatNote":     FormatNote,
		"label":          Label,
		"next":           Next,
		"isDeleted":      IsDeleted,
		"toTagList":      TagList,
		"toTag":          ToTag,
	}).ParseGlob(globPath)
	if err != nil {
		return nil, fmt.Errorf("failed to parse templates: %w", err)
	}

	return &Templater{
		tmpl:           tmpl,
		tags:           tags,
		recurrentTasks: recurrentTasks,
		outDir:         outDir,
	}, nil
}

func (t *Templater) WriteProject(pi everdo.Item, items []everdo.Item) error {
	ptData, err := t.toProjectTemplateData(pi, items)
	if err != nil {
		return fmt.Errorf("failed to generate project template data for project %q: %w", pi.ID, err)
	}

	projFile, err := os.Create(filepath.Join(t.outDir, queryEscape(pi.Title)+".md"))
	if err != nil {
		return fmt.Errorf("failed to create the project MD file: %w", err)
	}
	defer projFile.Close() // Best-effort

	if err := t.tmpl.ExecuteTemplate(projFile, "project.md", ptData); err != nil {
		return fmt.Errorf("failed to execute template: %w", err)
	}
	if err := projFile.Close(); err != nil {
		return fmt.Errorf("failed to close project MD file: %w", err)
	}

	return nil
}

func (t *Templater) WriteNonProjectItems(name string, items []everdo.Item) error {
	nptData, err := t.toNonProjectItemsTemplateData(items)
	if err != nil {
		return fmt.Errorf("failed to generate template data for non-project item list %q: %w", name, err)
	}

	listFile, err := os.Create(filepath.Join(t.outDir, queryEscape("Imported from Everdo / "+name)+".md"))
	if err != nil {
		return fmt.Errorf("failed to create the non-project MD file: %w", err)
	}
	defer listFile.Close() // Best-effort

	if err := t.tmpl.ExecuteTemplate(listFile, "non-project-items.md", nptData); err != nil {
		return fmt.Errorf("failed to execute template: %w", err)
	}

	if err := listFile.Close(); err != nil {
		return fmt.Errorf("failed to close non-project MD file: %w", err)
	}

	return nil
}

func (t *Templater) WriteNotebook(nbi everdo.Item, items []everdo.Item) error {
	nbData, err := t.toNotebookTemplateData(nbi, items)
	if err != nil {
		return fmt.Errorf("failed to generate template data for notebook %q: %w", nbi.ID, err)
	}

	nbFile, err := os.Create(filepath.Join(t.outDir, queryEscape(nbi.Title)+".md"))
	if err != nil {
		return fmt.Errorf("failed to create the notebook MD file: %w", err)
	}
	defer nbFile.Close()

	if err := t.tmpl.ExecuteTemplate(nbFile, "notebook.md", nbData); err != nil {
		return fmt.Errorf("failed to execute template: %w", err)
	}

	if err := nbFile.Close(); err != nil {
		return fmt.Errorf("failed to close notebook MD file: %w", err)
	}

	return nil
}

func scoreItem(i everdo.Item, childBeforeGlobal bool) []int {
	completedOn := 0
	if i.CompletedOn != nil {
		completedOn = *i.CompletedOn
	}
	position := 0
	if i.PositionChild != nil && i.CompletedOn == nil {
		position = -*i.PositionChild
	positionGlobal := 0
	if i.PositionGlobal != nil && i.CompletedOn == nil {
		positionGlobal = 10000000 - *i.PositionGlobal
	}
	return []int{
		focusedAndUncompleted(i),
		itemValue[i.List],
		position,
		completedOn,
		i.CreatedOn,
	positionChild := 0
	if i.PositionChild != nil && i.CompletedOn == nil {
		positionChild = 10000000 - *i.PositionChild
	}
	createdOnNotArchived := 0
	if i.CompletedOn == nil {
		createdOnNotArchived = 100000000000 - i.CreatedOn
	}

	if childBeforeGlobal {
		return []int{
			focusedAndUncompleted(i),
			itemValue[i.List],
			positionChild,
			positionGlobal,
			createdOnNotArchived,
			completedOn,
			i.CreatedOn,
		}
	} else {
		return []int{
			focusedAndUncompleted(i),
			itemValue[i.List],
			positionGlobal,
			positionChild,
			createdOnNotArchived,
			completedOn,
			i.CreatedOn,
		}
	}
}

func focusedAndUncompleted(i Item) int {
func focusedAndUncompleted(i everdo.Item) int {
	if i.IsFocused == 1 && i.List == "a" && i.CompletedOn == nil {
		return 2
	}


@@ 49,9 180,9 @@ func focusedAndUncompleted(i Item) int {
	return 0
}

func toTemplateData(p Project, tags map[string]Tag) (ProjectTemplateData, error) {
	slices.SortStableFunc(p.Items, func(a, b Item) int {
		scoreA, scoreB := scoreItem(a), scoreItem(b)
func sortItems(items []everdo.Item, childBeforeGlobal bool) {
	slices.SortStableFunc(items, func(a, b everdo.Item) int {
		scoreA, scoreB := scoreItem(a, childBeforeGlobal), scoreItem(b, childBeforeGlobal)
		for i := range scoreA {
			if scoreA[i] == scoreB[i] {
				continue


@@ 63,18 194,26 @@ func toTemplateData(p Project, tags map[string]Tag) (ProjectTemplateData, error)
		}
		return 0
	})
	pi := p.ProjectItem
	ps, err := toProjectStatus(pi.List)
	if err != nil {
		return ProjectTemplateData{}, fmt.Errorf("invalid project list: %w", err)
	}
	var items []ItemTemplateData
	for _, item := range p.Items {
		it, err := toItemTemplateData(item, tags)
}

func (t *Templater) toNonProjectItemsTemplateData(items []everdo.Item) (NonProjectItemsTemplateData, error) {
	sortItems(items, false)
	var itemData []ItemTemplateData
	for _, item := range items {
		it, err := t.toItemTemplateData(item)
		if err != nil {
			return ProjectTemplateData{}, fmt.Errorf("failed to convert item %q: %w", item.ID, err)
			return NonProjectItemsTemplateData{}, fmt.Errorf("failed to convert item %q: %w", item.ID, err)
		}
		items = append(items, it)
		itemData = append(itemData, it)
	}
	return NonProjectItemsTemplateData{Items: itemData}, nil
}

func (t *Templater) toProjectTemplateData(pi everdo.Item, inItems []everdo.Item) (ProjectTemplateData, error) {
	sortItems(inItems, true)
	ps, err := toProjectStatus(pi)
	if err != nil {
		return ProjectTemplateData{}, fmt.Errorf("invalid project list: %w", err)
	}

	pType := "sequential"


@@ 82,59 221,110 @@ func toTemplateData(p Project, tags map[string]Tag) (ProjectTemplateData, error)
		pType = "parallel"
	}

	area, nonAreaTags, err := areaAndTags(pi.Tags, tags)
	area, waitingOn, nonAreaTags, err := t.areaAndTags(pi)
	if err != nil {
		return ProjectTemplateData{}, fmt.Errorf("failed to get project tags: %w", err)
	}

	var items []ItemTemplateData
	for _, item := range inItems {
		it, err := t.toItemTemplateData(item)
		if err != nil {
			return ProjectTemplateData{}, fmt.Errorf("failed to convert item %q: %w", item.ID, err)
		}
		if area != "" && it.Area == area {
			// Clear out redundant area. If our project is part of an area, no need for
			// items to also carry that along.
			it.Area = ""
		}
		items = append(items, it)
	}

	return ProjectTemplateData{
		Description: strings.TrimSpace(pi.Note),
		// Properties in Logseq don't appear to support multiline text, so we just use two spaces.
		Description: strings.ReplaceAll(strings.TrimSpace(pi.Note), "\n", "  "),
		Type:        pType,
		Status:      ps,
		Items:       items,
		Area:        area,
		Tags:        nonAreaTags,
		WaitingOn:   waitingOn,
		Focus:       pi.IsFocused == 1,
	}, nil
}

func (t *Templater) toNotebookTemplateData(nbi everdo.Item, items []everdo.Item) (NotebookTemplateData, error) {
	sortItems(items, true)

	area, waitingOn, nonAreaTags, err := t.areaAndTags(nbi)
	if err != nil {
		return NotebookTemplateData{}, fmt.Errorf("failed to get notebook tags: %w", err)
	}
	if waitingOn != "" {
		return NotebookTemplateData{}, fmt.Errorf("notebook had a waiting on contact %q, which isn't a thing", waitingOn)
	}

	var notes []NoteTemplateData
	for _, item := range items {
		it, err := t.toNoteTemplateData(item)
		if err != nil {
			return NotebookTemplateData{}, fmt.Errorf("failed to convert note %q: %w", item.ID, err)
		}
		if area != "" && it.Area == area {
			// Clear out redundant area. If our notebook is part of an area, no need for
			// notes to also carry that along.
			it.Area = ""
		}
		notes = append(notes, it)
	}

	return NotebookTemplateData{
		// Properties in Logseq don't appear to support multiline text, so we just use two spaces.
		Description: strings.ReplaceAll(strings.TrimSpace(nbi.Note), "\n", "  "),
		Items:       notes,
		Area:        area,
		Tags:        nonAreaTags,
		Focus:       nbi.IsFocused == 1,
	}, nil
}

func areaAndTags(tagIDs []string, tags map[string]Tag) (string, []string, error) {
func (t *Templater) areaAndTags(item everdo.Item) (string, string, []string, error) {
	var (
		area    string
		nonArea []string
		area      string
		waitingOn string
		nonArea   []string
	)
	for _, tID := range tagIDs {
		t, ok := tags[tID]
	for _, tID := range item.Tags {
		t, ok := t.tags[tID]
		if !ok {
			return "", nil, fmt.Errorf("no tag found for tag ID %q", tID)
			return "", "", nil, fmt.Errorf("no tag found for tag ID %q", tID)
		}
		if t.Type == "a" {
			if area != "" {
				return "", nil, fmt.Errorf("multiple area tags for entity, found at least %q and %q", area, t.Title)
				return "", "", nil, fmt.Errorf("multiple area tags for entity, found at least %q and %q", area, t.Title)
			}
			area = t.Title
			continue
		}
		if item.ContactID != "" && tID == item.ContactID {
			if waitingOn != "" {
				return "", "", nil, fmt.Errorf("multiple contact ID tags for entity, found at least %q and %q", waitingOn, t.Title)
			}
			waitingOn = t.Title
			continue
		}
		nonArea = append(nonArea, t.Title)
	}
	return area, nonArea, nil
	return area, waitingOn, nonArea, nil
}

func toItemTemplateData(it Item, tags map[string]Tag) (ItemTemplateData, error) {
func (t *Templater) toItemTemplateData(it everdo.Item) (ItemTemplateData, error) {
	st, err := toItemStatus(it.List)
	if err != nil {
		return ItemTemplateData{}, fmt.Errorf("invalid item list: %w", err)
	}

	var waitingOn string
	if st == "waiting" && it.ContactID != "" {
		t, ok := tags[it.ContactID]
		if !ok {
			return ItemTemplateData{}, fmt.Errorf("no contact tag found for ID %q", it.ContactID)
		}
		waitingOn = t.Title
	}

	area, nonAreaTags, err := areaAndTags(it.Tags, tags)
	area, waitingOn, nonAreaTags, err := t.areaAndTags(it)
	if err != nil {
		return ItemTemplateData{}, fmt.Errorf("failed to get item tags: %w", err)
	}


@@ 148,7 338,7 @@ func toItemTemplateData(it Item, tags map[string]Tag) (ItemTemplateData, error) 

	var schedule string
	if it.List == "s" {
		sch, err := toSchedule(it)
		sch, err := t.toSchedule(it)
		if err != nil {
			return ItemTemplateData{}, fmt.Errorf("failed to format schedule: %w", err)
		}


@@ 179,12 369,48 @@ func toItemTemplateData(it Item, tags map[string]Tag) (ItemTemplateData, error) 
	}, nil
}

func (t *Templater) toNoteTemplateData(it everdo.Item) (NoteTemplateData, error) {
	area, waitingOn, nonAreaTags, err := t.areaAndTags(it)
	if err != nil {
		return NoteTemplateData{}, fmt.Errorf("failed to get note tags: %w", err)
	}
	if waitingOn != "" {
		return NoteTemplateData{}, fmt.Errorf("note had a waiting on contact %q, which isn't a thing", waitingOn)
	}

	var schedule string
	if it.List == "s" {
		if it.StartDate == nil {
			return NoteTemplateData{}, errors.New("list was 'scheduled', but no start date provided")
		}

		if it.Schedule != nil {
			return NoteTemplateData{}, fmt.Errorf("note had a full schedule %v, which isn't supported", *it.Schedule)
		}

		nrd, err := nonRepeatingDate(*it.StartDate)
		if err != nil {
			return NoteTemplateData{}, fmt.Errorf("failed to get non-repeating scheduled date for note: %w", err)
		}
		schedule = nrd
	}

	return NoteTemplateData{
		Title:    it.Title,
		Note:     strings.TrimSpace(it.Note),
		Focus:    it.IsFocused == 1,
		Tags:     nonAreaTags,
		Area:     area,
		Schedule: schedule,
	}, nil
}

type ItemSchedule struct {
	Schedule string
	Deadline string // Can be blank
}

func toSchedule(it Item) (ItemSchedule, error) {
func (t *Templater) toSchedule(it everdo.Item) (ItemSchedule, error) {
	if it.StartDate == nil && it.Schedule == nil {
		return ItemSchedule{}, errors.New("list was 'scheduled', but not start date or schedule provided")
	}


@@ 195,13 421,23 @@ func toSchedule(it Item) (ItemSchedule, error) {
	}

	if it.StartDate != nil && it.Schedule != nil {
		return repeatingDate(*it.StartDate, *it.Schedule)
		return t.repeatingDate(it)
	}

	return ItemSchedule{}, fmt.Errorf("unexpected combination of 'schedule' (%v) and 'start_date' (%v)", it.Schedule, it.StartDate)
}

func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
// XXX: The math around when the next iteration should occur is **extremely**
// shaky and probably just totally broken. If you need accurate dates, manually
// check any scheduled tasks you had.
func (t *Templater) repeatingDate(it everdo.Item) (ItemSchedule, error) {
	if it.StartDate == nil {
		return ItemSchedule{}, errors.New("no start date for repeating event item")
	}
	if it.Schedule == nil {
		return ItemSchedule{}, errors.New("no schedule for repeating event item")
	}
	start, s := *it.StartDate, *it.Schedule
	// "type": "Yearly",
	// "period": 1,
	// "daysOfWeek": null,


@@ 225,7 461,15 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
		return ItemSchedule{}, fmt.Errorf("invalid start date: %w", err)
	}

	maybeDeadline := func(repeat string) string {
	latestRecurrent := startDate
	for _, item := range t.recurrentTasks[it.ID] {
		createdOn := time.Unix(int64(item.CreatedOn), 0)
		if createdOn.After(latestRecurrent) {
			latestRecurrent = createdOn
		}
	}

	maybeDeadline := func(scheduledFor time.Time, repeat string) string {
		if s.AutoDueDate == nil {
			return ""
		}


@@ 233,9 477,8 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
			return ""
		}

		return startDate.
			AddDate(0, 0, s.DueDateDelay).
			Format("<2006-01-02 Mon " + repeat + ">")
		d := scheduledFor.AddDate(0, 0, s.DueDateDelay)
		return fmt.Sprintf("<%s %s>", d.Format("2006-01-02 Mon"), repeat)
	}

	repeatPrefix := func() string {


@@ 254,12 497,13 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
			// Update the start date based on when we want this to fire.
			doy := s.DaysOfYear[0]
			dom := doy.DayOfMonth
			year := latestRecurrent.Year()
			switch dom.Type {
			case "SpecificDate":
				// doy.Month is zero-indexed, e.g. April == 3, but Go months are one-indexed, e.g. April == 4
				startDate = time.Date(startDate.Year(), time.Month(doy.Month+1), dom.Date, 0, 0, 0, 0, startDate.Location())
				startDate = time.Date(year, time.Month(doy.Month+1), dom.Date, 0, 0, 0, 0, startDate.Location())
			case "LastDayOfMonth":
				startDate = time.Date(startDate.Year(), time.Month(doy.Month+1), 0, 0, 0, 0, 0, startDate.Location())
				startDate = time.Date(year, time.Month(doy.Month+1), 0, 0, 0, 0, 0, startDate.Location())
				startDate.AddDate(0, 1, -1)
			default:
				return ItemSchedule{}, fmt.Errorf("unknown DayOfMonth type %q", dom.Type)


@@ 267,10 511,16 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
		default:
			return ItemSchedule{}, fmt.Errorf("item repeated multiple (%d) times a year, not really supported by Logseq", len(s.DaysOfYear))
		}
		// If we're not working from the start date, increment forward <period> years,
		// since latestRecurrent is the last one that actually occurred.
		scheduledFor := latestRecurrent
		if !latestRecurrent.Equal(startDate) {
			scheduledFor = scheduledFor.AddDate(s.Period, 0, 0)
		}
		repeat := repeatPrefix() + strconv.Itoa(s.Period) + "y"
		return ItemSchedule{
			Schedule: startDate.Format(fmt.Sprintf("<2006-01-02 Mon %s>", repeat)),
			Deadline: maybeDeadline(repeat),
			Schedule: fmt.Sprintf("<%s %s>", scheduledFor.Format("2006-01-02 Mon"), repeat),
			Deadline: maybeDeadline(scheduledFor, repeat),
		}, nil
	case "Monthly":
		// "schedule": {


@@ 297,23 547,29 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
		case 1:
			// Update the start date based on when we want this to fire.
			dom := s.DaysOfMonth[0]
			month := startDate.Month()
			year, month := latestRecurrent.Year(), latestRecurrent.Month()
			switch dom.Type {
			case "SpecificDate":
				startDate = time.Date(startDate.Year(), month, dom.Date, 0, 0, 0, 0, startDate.Location())
				startDate = time.Date(year, month, dom.Date, 0, 0, 0, 0, startDate.Location())
			case "LastDayOfMonth":
				startDate = time.Date(startDate.Year(), month, 0, 0, 0, 0, 0, startDate.Location())
				startDate = time.Date(year, month, 0, 0, 0, 0, 0, startDate.Location())
				// Add one month, subtract one day to get the end of the month
				startDate.AddDate(0, 1, -1)
			}
			repeat := repeatPrefix() + strconv.Itoa(s.Period) + "m"
			return ItemSchedule{
				Schedule: startDate.Format(fmt.Sprintf("<2006-01-02 Mon %s>", repeat)),
				Deadline: maybeDeadline(repeat),
			}, nil
		default:
			return ItemSchedule{}, fmt.Errorf("item repeated multiple (%d) times a month, not really supported by Logseq", len(s.DaysOfMonth))
		}
		// If we're not working from the start date, increment forward <period> months,
		// since latestRecurrent is the last one that actually occurred.
		scheduledFor := latestRecurrent
		if !latestRecurrent.Equal(startDate) {
			scheduledFor = scheduledFor.AddDate(0, s.Period, 0)
		}
		repeat := repeatPrefix() + strconv.Itoa(s.Period) + "m"
		return ItemSchedule{
			Schedule: fmt.Sprintf("<%s %s>", startDate.Format("2006-01-02 Mon"), repeat),
			Deadline: maybeDeadline(scheduledFor, repeat),
		}, nil
	case "Weekly":
		// "schedule": {
		//   "type": "Weekly",


@@ 333,27 589,37 @@ func repeatingDate(start int, s Schedule) (ItemSchedule, error) {
		case 0:
			return ItemSchedule{}, errors.New("item repeated weekly but had no week configuration")
		case 1:
			dow := s.DaysOfWeek[0]

			// Update the start date based on when we want this to fire, just keep ticking forward until our start date is the next day of the week we want.
			// Weekday, sundays are zero in time.Weekday and ***I THINK*** also in Everdo
			sanityCheck := 0
			for dow != int(startDate.Weekday()) {
				startDate = startDate.AddDate(0, 0, 1)
				sanityCheck++
				if sanityCheck >= 7 {
					return ItemSchedule{}, fmt.Errorf("added %d days to start date and still didn't find week day %d, was on %d", sanityCheck, dow, startDate.Weekday())
				}
			}

			repeat := repeatPrefix() + strconv.Itoa(s.Period) + "d"
			return ItemSchedule{
				Schedule: startDate.Format(fmt.Sprintf("<2006-01-02 Mon %s>", repeat)),
				Deadline: maybeDeadline(repeat),
			}, nil
			// Okay, continue
		default:
			return ItemSchedule{}, fmt.Errorf("item repeated multiple (%d) times a week, not really supported by Logseq", len(s.DaysOfWeek))
		}

		dow := s.DaysOfWeek[0]

		// If we're not working from the start date, increment forward <period> weeks,
		// since latestRecurrent is the last one that actually occurred.
		scheduledFor := latestRecurrent
		if !latestRecurrent.Equal(startDate) {
			scheduledFor = scheduledFor.AddDate(0, 0, s.Period*7)
		}

		// Update the start date based on when we want this to fire, just keep ticking
		// forward until our start date is the next day of the week we want. Sundays are
		// zero in time.Weekday and ***I THINK*** also in Everdo
		sanityCheck := 0
		for dow != int(scheduledFor.Weekday()) {
			startDate = scheduledFor.AddDate(0, 0, 1)
			sanityCheck++
			if sanityCheck >= 7 {
				return ItemSchedule{}, fmt.Errorf("added %d days to scheduledFor and still didn't find week day %d, was on %d", sanityCheck, dow, scheduledFor.Weekday())
			}
		}

		repeat := repeatPrefix() + strconv.Itoa(s.Period) + "d"
		return ItemSchedule{
			Schedule: fmt.Sprintf("<%s %s>", startDate.Format("2006-01-02 Mon"), repeat),
			Deadline: maybeDeadline(scheduledFor, repeat),
		}, nil
	default:
		return ItemSchedule{}, fmt.Errorf("unknown (or unsupported) schedule frequency %q", s.Type)
	}


@@ 383,8 649,12 @@ func nonRepeatingDate(d int) (string, error) {
	return dd.Format("<2006-01-02 Mon>"), nil
}

func toProjectStatus(projectList string) (string, error) {
	switch projectList {
func toProjectStatus(pi everdo.Item) (string, error) {
	if pi.CompletedOn != nil {
		return "archived", nil
	}

	switch pi.List {
	case "a": // active/next (for projects, it's "active")
		return "active", nil
	case "m": // someday


@@ 398,7 668,7 @@ func toProjectStatus(projectList string) (string, error) {
	case "r": // archived (must also specify `completed_on`)
		return "archived", nil
	default:
		return "", fmt.Errorf("unknown status %q", projectList)
		return "", fmt.Errorf("unknown status %q", pi.List)
	}
}



@@ 431,12 701,38 @@ type ProjectTemplateData struct {
	Description string
	Type        string // sequential or parallel
	Status      string // active, someday, waiting, deleted, archived
	Focus       bool

	WaitingOn string // If status is 'waiting', this can optionally be set

	Items []ItemTemplateData
	Area  string
	Tags  []string
}

type NotebookTemplateData struct {
	Description string
	Focus       bool

	Items []NoteTemplateData
	Area  string
	Tags  []string
}

type NoteTemplateData struct {
	Title string
	Note  string

	Focus bool

	Area string

	// If status is 'scheduled', this is set from either `start_date` or `schedule`
	Schedule string // Formatted as Logseq "<YYYY-MM-DD {day} {optional time} +{Xy/m/w/d/h}>"

	Tags []string
}

type ItemTemplateData struct {
	Title string
	Note  string


@@ 477,7 773,7 @@ func indent(lines []string, level int) string {
	return strings.Join(lines, "\n")
}

func FormatNote(s string) string {
func FormatItemNote(s string) string {
	// Handle sub-items
	var out []string
	i := 0


@@ 504,6 800,32 @@ func FormatNote(s string) string {
	return indent(out, 2)
}

// For notes, we don't want to put TODOs and DONEs, they're just actually bullet points.
func FormatNote(s string) string {
	// Handle sub-items
	var out []string
	i := 0
	for _, l := range strings.Split(s, "\n") {
		l = strings.TrimSpace(l)
		if l == "" {
			continue
		}

		prefix := "- "
		if i == 0 {
			// First line gets a - by default in the template
			prefix = ""
		}
		if strings.HasPrefix(l, "- ") {
			out = append(out, l)
		} else {
			out = append(out, prefix+l)
		}
		i++
	}
	return indent(out, 2)
}

func Next(itd ItemTemplateData) bool {
	return itd.Status == "next"
}


@@ 562,3 884,8 @@ func hasSpace(in string) bool {
	}
	return false
}

func queryEscape(in string) string {
	// Spaces are okay
	return strings.ReplaceAll(url.QueryEscape(in), "+", " ")
}

M tmpl/item.md => tmpl/item.md +4 -3
@@ 6,14 6,15 @@
  {{ end -}}
  {{ if (and (not .HasParent) (next .)) }}inbox:: false
  {{ end -}}
  {{ if .Area}}area:: [[{{ .Area }}]]
  {{ if .Area }}area:: [[{{ .Area }}]]
  {{ end -}}
  {{ if .Schedule }}SCHEDULED: {{ .Schedule }}
  {{ end -}}
  {{ if .Deadline}}DEADLINE: {{ .Deadline }}
  {{ end -}}
  collapsed:: true
  {{ if .Note }}collapsed:: true
  {{ end -}}
  {{ if .Note -}}
  - {{ formatNote .Note }}
  - {{ formatItemNote .Note }}
  {{ end }}
{{- end }}

M tmpl/non-project-items.md => tmpl/non-project-items.md +0 -1
@@ 1,5 1,4 @@
imported-from:: everdo

{{ range .Items -}}
	{{- template "item" . -}}
{{ end }}

A tmpl/note.md => tmpl/note.md +15 -0
@@ 0,0 1,15 @@
{{ define "note" }}
- {{ .Title }}{{ if .Tags }} {{ toTagList .Tags }}{{ end }}
  {{ if .Focus }}focus:: true
  {{ end -}}
  {{ if .Area}}area:: [[{{ .Area }}]]
  {{ end -}}
  {{ if .Schedule }}SCHEDULED: {{ .Schedule }}
  {{ end -}}
  {{ if .Note -}}collapsed:: true
  {{ end -}}
  {{ if .Note -}}
  - {{ formatNote .Note }}
  {{ end }}
{{- end }}


A tmpl/notebook.md => tmpl/notebook.md +13 -0
@@ 0,0 1,13 @@
imported-from:: everdo
{{ if .Description }}description:: {{ .Description }}
{{ end -}}
{{ if .Focus }}focus:: true
{{ end -}}
{{ if .Tags }}tags:: {{ toTagList .Tags }}
{{ end -}}
{{ if .Area }}area:: {{ toTag .Area }}
{{ end -}}

{{ range .Items -}}
	{{- template "note" . -}}
{{ end }}

M tmpl/project.md => tmpl/project.md +10 -3
@@ 1,9 1,16 @@
type:: project
description:: {{ .Description }}
{{if .Description}}description:: {{ .Description }}
{{ end -}}
{{ if .Focus }}focus:: true
{{ end -}}
project-type:: {{ .Type }}
project-status:: {{ .Status }}
tags:: {{ toTagList .Tags }}
{{ if .Area }}area:: {{ toTag .Area }}{{ end -}}
{{ if .WaitingOn }}waiting-on:: [[{{ .WaitingOn }}]]
{{ end -}}
{{ if .Tags }}tags:: {{ toTagList .Tags }}
{{ end -}}
{{ if .Area }}area:: {{ toTag .Area }}
{{ end -}}

{{ range .Items -}}
	{{- template "item" . -}}