amtoaer

晓风残月

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

medum - コマンドラインのタスク管理ツール

数日前にコマンドラインのタスク管理ツールを作りました:medum。しばらく記事を投稿していなかったことに気づき、今夜は時間があるので、これを取り出して軽く話してみようと思います。ついでに水を差す記事も 233((

名前の由来#

このプロジェクトはmedumと呼ばれています。なぜなら、その開発目的は私がddlを忘れないようにするためで、最初からメモ(memorandum)として設計されていました。memorandumという単語から最初の 2 文字と最後の 3 文字を組み合わせて、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はイベント名、開始日と終了日はタイムスタンプで保存。
        // 状態マークについては、イベントには未開始、進行中、終了の3つの状態があるため、isEndは0,1,2を取ることができ、対応する3つの状態を示す。
    	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
    }
    // 残りの関数ロジックは類似しており、そのうちの1つを例として挙げます:
    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
    	}
    	//無駄な行、警告を防ぐためだけ
    	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)。自分のコードはかなり読みやすいと思うので、皆さんのご意見をお待ちしています!

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。