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, includingConfiguration
(configuration file) andEvent
(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#
-
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.
-
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
, thenconfig.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) }
-
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 writetext.go
first. However, before that, I had to consider the content of the event structure, so the final order ispublic.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 }
-
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...) }
-
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!