amtoaer

晓风残月

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

medum-一款命令行待办事项管理器

前几天整了一个命令行待办事项管理器:medum。注意到自己已经有一阵子没发过文章了,正好今晚有空,把它拿出来随便聊聊,顺便水一篇 233((

名称由来#

这个项目叫做medum。因为它的开发目的是防止我忘记ddl,所以从一开始,它就是作为一个备忘录(memorandum)被设计的。我从memorandum这个单词中取了前两个和后三个字母进行拼接,于是medum诞生了。

代码结构#

具体的开发初衷已经写在了README.md里,所以接下来就说说代码结构吧。

下面是所有的代码文件:

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

每个模块的功能如下:

  • path.go:负责获取配置文件夹路径,配置文件路径和 sqlite 数据库路径。
  • config.go:负责读取配置文件,并在配置文件不存在的情况下写入默认配置。
  • public.go:存放公共的结构体,包括在多个文件进行引用的Configuration(配置文件)、Event(事项)。
  • sqlite.go:sqlite 数据库的打开,插入,更新,查询,删除功能的实现。
  • text.go:存放该程序近乎所有的文本信息(包括报错、提醒以及 sql 语句)。
  • output.go:将配置文件的颜色映射为函数,对彩色输出函数的反射调用进行一层封装。

设计思路#

  1. 首先从功能出发,既然要读取文件,那么获取路径是必须的,于是首先考虑实现path.go

    // path.go
    func GetPath() string { //获取文件夹路径,路径为~/.medum
    	path, err := homedir.Dir()
    	if err != nil {
    		fmt.Printf(text.HomedirError, err)
    		os.Exit(1)
    	}
    	return filepath.Join(path, ".medum")
    }
    // 后面获取配置文件和数据的路径很简单,在文件夹路径后加文件名就可以
    
  2. 有了路径,接下来就是要到路径中读取文件啦,但文件读入到哪儿呢?为了让内容有处可去,应该先实现一个配置文件的结构体。考虑到配置文件自定义的是颜色,必定需要被输出模块读取,所以将其分离放在public.go里,接着再写config.go

    // public.go
    type Configuration struct {
    	NumberColor string
    	EventColor  string
    	TimeColor   string
    }
    
    // config.go
    func ReadConfig() *public.Configuration {
    	configPath := path.GetConfigPath()
    	// 检测配置文件是否存在,不存在则写入默认配置
    	if _, err := os.Stat(configPath); err != nil && !os.IsExist(err) {
    		writeInitConfig(configPath)
    	}
        // ...
    	decoder := json.NewDecoder(file)
    	conf := new(public.Configuration)
        // 将文件读取到conf中
    	err = decoder.Decode(conf)
        // ...
    	return conf
        
    func writeInitConfig(configPath string) {
        // 首先看文件夹是否存在,如果不存在则新建
    	tmp := path.GetPath()
    	if _, err := os.Stat(tmp); !os.IsExist(err) {
    		os.Mkdir(tmp, os.FileMode(0777))
    	}
        // 接着新建配置文件
    	file, err := os.Create(configPath)
    	// 创建默认配置
    	conf := public.Configuration{
    		NumberColor: "red",
    		EventColor:  "blue",
    		TimeColor:   "yellow",
    	}
        // 写入
    	encoder := json.NewEncoder(file)
    	encoder.Encode(conf)
    }
    
  3. 实现了配置文件的读取,紧接着就是数据的操作了,考虑实现sqlite.go,但为了方便修改,尽量将所有的文本内容与逻辑分离,需要先写text.go,但在这之前,还要先考虑好事件结构体的内容,所以最终顺序是public.go->text.go->sqlite.go

    // public.go
    // 包括id,事件内容,开始结束日期,当前状态
    type Event struct {
    	ID           int
    	EventContent string
    	BeginDate    int64
    	EndDate      int64
    	IsEnd        uint8
    }
    
    // text.go
    const (
        // 创建表,其中id自增,event为事件名,开始日期结束日期用时间戳存储。
        // 状态标记方面,一个事件应该有未开始,正在进行,结束三个状态,故isEnd可取0,1,2,对应三种状态
    	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
    	);`
        // 插入一行
    	InsertRow      = `insert into eventList (event,beginDate,endDate) values (?,?,?);`
        // 当前时间大于结束时间则标记超时
    	MarkOutdate    = `update eventList set isEnd=2 where ?>endDate;`
        // 当前时间小于开始时间则标记未开始
    	MarkNotStart   = `update eventList set isEnd=0 where ?<beginDate;`
        // 介于开始结束之间标记正在进行
    	MarkInProgress = `update eventList set isEnd=1 where ? between beginDate and endDate;`
        // 得到所有的事件,按事件状态,结束日期排序
    	QueryRow       = `select * from eventList order by isEnd desc,endDate`
        // 删除超时事件
    	DeleteOutDate  = `delete from eventList where isEnd=2`
        // 对于完成了的事件,通过id删除
    	DeleteID       = `delete from eventList where id=?`
    )
    
    // sqlite.go
    // 打开数据库
    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
    }
    // 剩余函数逻辑类似,取其中之一举例:
    func InsertSqliteDB(eventContent string, beginDate, endDate time.Time) error {
        // 打开数据库
    	db := openSqliteDB()
        // 结束时关闭
    	defer db.Close()
        // 执行语句
    	_, err := db.Exec(text.InsertRow, eventContent, beginDate.Unix(), endDate.Unix())
    	return err
    }
    
  4. 接下来考虑实现output.go

    // output.go
    // 支持的所有颜色(采用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),
    }
    // 使用reflect包进行动态调用
    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)
    }
    
    // 将call进行一层封装,对外公开Call函数
    func Call(color string, params ...interface{}) {
    	call(funcs, color, params...)
    }
    
    
  5. 万事俱备,最后只需要在主函数中完成调用逻辑即可(当然还需要补全一些错误输出、给用户的提示等等,不过那些已经很简单了):

    // main.go
    // 接受的命令行参数
    var (
    	begin  string
    	end    string
    	name   string
    	remove bool
    	done   int
    )
    
    func main() {
    	app := &cli.App{
    		// 省略掉参数绑定等流程
    		Action: func(c *cli.Context) error {
                // 读取配置文件
    			conf := *config.ReadConfig()
    			if remove {
                    // -r:首先标记过期事件,接着删除之
    				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: 直接删除该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 { 
                      // 如果没有-d,打印事件列表
                      // 这里只标记进行中和超时,是因为在插入事件时已经标记了是否未开始,时间顺序流动,不可能从开始变为未开始
    					sqlite.UpdateSqliteDB(text.MarkInProgress)
    					sqlite.UpdateSqliteDB(text.MarkOutdate)
    					// ...省略输出部分
    					}
    				} else { 
                    // 如果存在-e,则解析结束时间,开始事件如果有就解析,没有默认为当前时间
    					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)
    					}
                    // 将输入事件插入
    					err := sqlite.InsertSqliteDB(name, beginTime, endTime)
    					if err != nil {
    						fmt.Printf(text.InsertDBError, err)
    					}
                    // 标记未开始
    					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 {
        // 时间字符串转time.Time,只接受month.day.hour.minute格式
    	tmp := strings.Split(tm, ".")
        // 长度不为4则退出
    	if len(tmp) != 4 {
    		fmt.Println(text.LengthError)
    		os.Exit(1)
    	} else {
            // 补全0,例如将5.20.12.0补全为05.20.12.00,防止解析错误
    		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 {
        // 将时间转换成%s time remaining/%s time beginning格式,其中的%s调用下面的formatTimeString函数获取
    	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 {
        // 获取天/小时/分钟
    	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
    }
    

这样下来,预想的功能就实现的差不多了,任务成功完成。

结束语#

okk,这样就结束啦,算是写了个小总结吧。

代码基本全程都有注释(不过为了符合golang的规范,使用的是塑料英语 XD),觉得自己代码写的还是蛮易读的,欢迎大家阅读给出建议哦!

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。