amtoaer

晓风残月

叹息似的渺茫,你仍要保存着那真!
github
x
telegram
steam
nintendo switch
email

medum - A command-line to-do list manager

A few days ago, I created a command line to-do list manager: medum. I noticed that I haven't posted an article in a while, so I have some free time tonight to casually chat about it and write a little something, just for fun 233 ((

Origin of the Name#

This project is called medum. Its development purpose is to prevent me from forgetting ddl, so from the very beginning, it was designed as a memorandum. I took the first two and the last three letters from the word memorandum to create medum.

Code Structure#

The specific development intentions have been written in the README.md, so next, let's talk about the code structure.

Here are all the code files:

.
├── config
│   └── config.go
├── main.go
├── output
│   └── output.go
├── path
│   └── path.go
├── public
│   └── public.go
├── sqlite
│   └── sqlite.go
└── text
    └── text.go

The functions of each module are as follows:

  • path.go: Responsible for obtaining the configuration folder path, configuration file path, and sqlite database path.
  • config.go: Responsible for reading the configuration file and writing default configurations if the configuration file does not exist.
  • public.go: Stores common structures, including Configuration (configuration file) and Event (task) that are referenced in multiple files.
  • sqlite.go: Implements the functions for opening, inserting, updating, querying, and deleting in the sqlite database.
  • text.go: Stores almost all text information of the program (including error messages, reminders, and SQL statements).
  • output.go: Maps the colors of the configuration file to functions, encapsulating the reflection calls for colored output functions.

Design Concept#

  1. First, starting from functionality, since we need to read files, obtaining the path is essential, so I first considered implementing path.go:

    // path.go
    func GetPath() string { // Get folder path, path is ~/.medum
    	path, err := homedir.Dir()
    	if err != nil {
    		fmt.Printf(text.HomedirError, err)
    		os.Exit(1)
    	}
    	return filepath.Join(path, ".medum")
    }
    // Later, obtaining the paths for the configuration file and data is simple; just add the filename to the folder path.
    
  2. With the path established, the next step is to read files from that path, but where to read the files into? To ensure the content has a place to go, a structure for the configuration file should be implemented first. Considering that the configuration file customizes colors, it must be read by the output module, so it is separated and placed in public.go, then config.go is written:

    // public.go
    type Configuration struct {
    	NumberColor string
    	EventColor  string
    	TimeColor   string
    }
    
    // config.go
    func ReadConfig() *public.Configuration {
    	configPath := path.GetConfigPath()
    	// Check if the configuration file exists; if not, write the default configuration
    	if _, err := os.Stat(configPath); err != nil && !os.IsExist(err) {
    		writeInitConfig(configPath)
    	}
        // ...
    	decoder := json.NewDecoder(file)
    	conf := new(public.Configuration)
        // Read the file into conf
    	err = decoder.Decode(conf)
        // ...
    	return conf
        
    func writeInitConfig(configPath string) {
        // First, check if the folder exists; if not, create it
    	tmp := path.GetPath()
    	if _, err := os.Stat(tmp); !os.IsExist(err) {
    		os.Mkdir(tmp, os.FileMode(0777))
    	}
        // Then, create the configuration file
    	file, err := os.Create(configPath)
    	// Create default configuration
    	conf := public.Configuration{
    		NumberColor: "red",
    		EventColor:  "blue",
    		TimeColor:   "yellow",
    	}
        // Write it
    	encoder := json.NewEncoder(file)
    	encoder.Encode(conf)
    }
    
  3. After implementing the reading of the configuration file, the next step is to handle data operations. I considered implementing sqlite.go, but to facilitate modifications, I aimed to separate all text content from logic, so I needed to write text.go first. However, before that, I had to consider the content of the event structure, so the final order is public.go -> text.go -> sqlite.go:

    // public.go
    // Includes id, event content, start and end dates, current status
    type Event struct {
    	ID           int
    	EventContent string
    	BeginDate    int64
    	EndDate      int64
    	IsEnd        uint8
    }
    
    // text.go
    const (
        // Create table, where id auto-increments, event is the event name, start and end dates are stored as timestamps.
        // For status marking, an event should have three states: not started, in progress, and ended, thus isEnd can take values 0, 1, 2, corresponding to the three states.
    	CreateTable = `create table if not exists eventList(
    		id integer primary key autoincrement,
    		event varchar(100) not null,
    		beginDate unsigned bigint not null,
    		endDate unsigned bigint not null,
    		isEnd smallint default 1 not null
    	);`
        // Insert a row
    	InsertRow      = `insert into eventList (event,beginDate,endDate) values (?,?,?);`
        // If the current time is greater than the end time, mark as overdue
    	MarkOutdate    = `update eventList set isEnd=2 where ?>endDate;`
        // If the current time is less than the start time, mark as not started
    	MarkNotStart   = `update eventList set isEnd=0 where ?<beginDate;`
        // Mark as in progress between start and end
    	MarkInProgress = `update eventList set isEnd=1 where ? between beginDate and endDate;`
        // Get all events, sorted by event status and end date
    	QueryRow       = `select * from eventList order by isEnd desc,endDate`
        // Delete overdue events
    	DeleteOutDate  = `delete from eventList where isEnd=2`
        // For completed events, delete by id
    	DeleteID       = `delete from eventList where id=?`
    )
    
    // sqlite.go
    // Open database
    func openSqliteDB() *sql.DB {
    	db, err := sql.Open("sqlite3", path.GetDataPath())
    	if err != nil {
    		fmt.Printf(text.OpenDBError, err)
    		os.Exit(1)
    	}
    	db.Exec(text.CreateTable)
    	return db
    }
    // The remaining function logic is similar; taking one as an example:
    func InsertSqliteDB(eventContent string, beginDate, endDate time.Time) error {
        // Open database
    	db := openSqliteDB()
        // Close when done
    	defer db.Close()
        // Execute statement
    	_, err := db.Exec(text.InsertRow, eventContent, beginDate.Unix(), endDate.Unix())
    	return err
    }
    
  4. Next, consider implementing output.go:

    // output.go
    // All supported colors (stored using map[string]interface{})
    var funcs = map[string]interface{}{
    	"red":       color.New(color.FgRed),
    	"blue":      color.New(color.FgBlue),
    	"cyan":      color.New(color.FgCyan),
    	"green":     color.New(color.FgGreen),
    	"yellow":    color.New(color.FgYellow),
    	"magenta":   color.New(color.FgMagenta),
    	"white":     color.New(color.FgWhite),
    	"black":     color.New(color.FgBlack),
    	"hired":     color.New(color.FgHiRed),
    	"hiblue":    color.New(color.FgHiBlue),
    	"hicyan":    color.New(color.FgHiCyan),
    	"higreen":   color.New(color.FgHiGreen),
    	"hiyellow":  color.New(color.FgHiYellow),
    	"himagenta": color.New(color.FgHiMagenta),
    	"hiwhite":   color.New(color.FgHiWhite),
    	"hiblack":   color.New(color.FgHiBlack),
    }
    // Use the reflect package for dynamic calls
    func call(m map[string]interface{}, color string, params ...interface{}) {
    	function := reflect.ValueOf(m[color]).MethodByName("Printf")
    	in := make([]reflect.Value, len(params))
    	for index, param := range params {
    		in[index] = reflect.ValueOf(param)
    	}
    	function.Call(in)
    }
    
    // Encapsulate call and expose Call function
    func Call(color string, params ...interface{}) {
    	call(funcs, color, params...)
    }
    
    
  5. Everything is ready, and finally, we just need to complete the calling logic in the main function (of course, we also need to fill in some error outputs, user prompts, etc., but those are quite simple):

    // main.go
    // Command line parameters accepted
    var (
    	begin  string
    	end    string
    	name   string
    	remove bool
    	done   int
    )
    
    func main() {
    	app := &cli.App{
    		// Omitted parameter binding and other processes
    		Action: func(c *cli.Context) error {
                // Read configuration file
    			conf := *config.ReadConfig()
    			if remove {
                    // -r: First mark overdue events, then delete them
    				sqlite.UpdateSqliteDB(text.MarkOutdate)
    				err := sqlite.DeleteOutDate()
    				if err != nil {
    					fmt.Printf(text.DeleteOutdateError, err)
    					os.Exit(1)
    				}
    				fmt.Println(text.DeleteOutdateSuccess)
    			} else if done != 0 {
                    // -d int: Directly delete the event with that id
    				err := sqlite.DeleteID(done)
    				if err != nil {
    					fmt.Printf(text.DeleteIDError, err)
    					os.Exit(1)
    				}
    				fmt.Println(text.DeleteIDSuccess)
    			} else {
    				if len(end) == 0 { 
                      // If there is no -d, print the event list
                      // Here, only mark ongoing and overdue, because when inserting events, it has already been marked as not started; time flows in order, and it cannot change from started to not started.
    					sqlite.UpdateSqliteDB(text.MarkInProgress)
    					sqlite.UpdateSqliteDB(text.MarkOutdate)
    					// ... omitted output part
    					}
    				} else { 
                    // If there is an -e, parse the end time; if there is a start event, parse it; if not, default to the current time.
    					var beginTime, endTime time.Time
    					endTime = handleString(end)
    					if len(begin) == 0 {
    						beginTime = time.Now()
    					} else {
    						beginTime = handleString(begin)
    					}
    					if beginTime.Unix() >= endTime.Unix() {
    						fmt.Println(text.TimeError)
    						os.Exit(1)
    					}
                    // Insert the input event
    					err := sqlite.InsertSqliteDB(name, beginTime, endTime)
    					if err != nil {
    						fmt.Printf(text.InsertDBError, err)
    					}
                    // Mark as not started
    					sqlite.UpdateSqliteDB(text.MarkNotStart)
    					fmt.Println(text.InsertSuccess)
    				}
    			}
    			return nil
    		}}
    	err := app.Run(os.Args)
    	if err != nil {
    		log.Fatal(err)
    	}
    }
    func handleString(tm string) time.Time {
        // Convert time string to time.Time, only accepts month.day.hour.minute format
    	tmp := strings.Split(tm, ".")
        // If the length is not 4, exit
    	if len(tmp) != 4 {
    		fmt.Println(text.LengthError)
    		os.Exit(1)
    	} else {
            // Pad with 0, for example, convert 5.20.12.0 to 05.20.12.00 to prevent parsing errors
    		for index := range tmp { 
    			if len(tmp[index]) == 1 {
    				tmp[index] = "0" + tmp[index]
    			}
    		}
    		stdString := fmt.Sprintf(text.MyTime, strconv.Itoa(time.Now().Year()), tmp[0], tmp[1], tmp[2], tmp[3])
    		result, err := time.ParseInLocation(text.StandardTime, stdString, time.Now().Local().Location())
    		if err != nil {
    			fmt.Printf(text.ParamError, err)
    			os.Exit(1)
    		}
    		return result
    	}
    	// useless line, just to prevent warning
    	return time.Now()
    }
    
    func formatTime(begin, end int64, IsEnd uint8) string {
        // Convert time to %s time remaining/%s time beginning format, where %s calls the formatTimeString function below to obtain
    	now := time.Now()
    	if IsEnd == 0 {
    		beginTime := time.Unix(begin, 0)
    		dur := beginTime.Sub(now)
    		return fmt.Sprintf(text.TimeBeginning, formatTimeString(dur.Minutes()))
    	} else if IsEnd == 1 {
    		endTime := time.Unix(end, 0)
    		dur := endTime.Sub(now)
    		return fmt.Sprintf(text.TimeRemaining, formatTimeString(dur.Minutes()))
    	} else {
    		return fmt.Sprintf(text.TimeRemaining, "no time")
    	}
    }
    
    func formatTimeString(min float64) string {
        // Get days/hours/minutes
    	var tmp string
    	if min > 1440 {
    		tmp = strconv.Itoa(int(min/1440)) + " days"
    	} else if min > 60 {
    		tmp = strconv.Itoa(int((min / 60))) + " hours"
    	} else {
    		tmp = strconv.Itoa(int(min)) + " minutes"
    	}
    	return tmp
    }
    

With this, the expected functionality has been mostly achieved, and the task is successfully completed.

Conclusion#

Okay, that's it! This can be considered a little summary.

The code has comments throughout (though to comply with golang standards, I used plastic English XD), and I think my code is quite readable. Everyone is welcome to read and provide suggestions!

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.