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),覺得自己代碼寫的還是蠻易讀的,歡迎大家閱讀給出建議哦!

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。