commit 8caf0ac3aaef36a9d76f9d28bc560154455f10a1 Author: haopengzhan Date: Mon Sep 2 04:59:36 2024 +0000 Initial commit: Added framework to generate DOT and png files of character map chart from data folder. Added all figures in the 1-2 chapters. Added font file SimSum for render chart diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..01d77d0 --- /dev/null +++ b/Makefile @@ -0,0 +1,24 @@ +# Go parameters +GOCMD=go +GOBUILD=$(GOCMD) build +GOTEST=$(GOCMD) test +GOCLEAN=$(GOCMD) clean + +# Binary names +BINARY_NAME=xyj-figures +OUTPUT_DIR=output + +all: build + +build: + $(GOBUILD) -o $(OUTPUT_DIR)/$(BINARY_NAME) ./cmd/main.go + +run: build + $(OUTPUT_DIR)/$(BINARY_NAME) + +test: + $(GOTEST) -v ./... + +clean: + $(GOCLEAN) + rm -rf $(OUTPUT_DIR) \ No newline at end of file diff --git a/Readme.md b/Readme.md new file mode 100644 index 0000000..c4853c6 --- /dev/null +++ b/Readme.md @@ -0,0 +1,4 @@ +# xyj-figures: 西游记人物关系图 + +本项目基于通行本西游记总结 + diff --git a/SimSum.ttc b/SimSum.ttc new file mode 100644 index 0000000..91ad1dc Binary files /dev/null and b/SimSum.ttc differ diff --git a/cmd/main.go b/cmd/main.go new file mode 100644 index 0000000..9bf10f0 --- /dev/null +++ b/cmd/main.go @@ -0,0 +1,54 @@ +package main + +import ( + "os" + "xyj-figures/pkg/batch" + + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var ( + rootCmd = &cobra.Command{ + Use: "xyj-figures", + Short: "A binary to generate map chart for figures in 西游记", + Run: func(cmd *cobra.Command, args []string) { + es, rs, errs := batch.LoadFolder("data") + if errs != nil { + log.Errorf("unable to load data on folder %s: %v", "data", errs) + } + log.Infof("loaded %d entities and %d relationships", len(es), len(rs)) + + ns, rs, errs := batch.DistillData(es, rs) + if errs != nil { + log.Errorf("unable to distill data: %v", errs) + } + + errs = batch.Save("output/output.dot", ns, rs) + if errs != nil { + log.Errorf("unable to save dot: %v", errs) + } + }, + } +) + +// Execute executes the root command. +func Execute() error { + return rootCmd.Execute() +} + +func main() { + log.SetFormatter(&log.JSONFormatter{}) + + // Output to stdout instead of the default stderr + // Can be any io.Writer, see below for File example + log.SetOutput(os.Stdout) + + // Only log the warning severity or above. + log.SetLevel(log.InfoLevel) + + if err := rootCmd.Execute(); err != nil { + log.Fatalf("failed to exec cmd: %v", err) + os.Exit(1) + } +} diff --git a/data/character/丘弘济真人.yaml b/data/character/丘弘济真人.yaml new file mode 100644 index 0000000..47db510 --- /dev/null +++ b/data/character/丘弘济真人.yaml @@ -0,0 +1,12 @@ +schema: v1 +type: character +details: + name: 丘弘济真人 + other_names: + relationships: + - source: 玉皇大帝 + destination: 丘弘济真人 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/唐僧.yaml b/data/character/唐僧.yaml new file mode 100644 index 0000000..f3d257a --- /dev/null +++ b/data/character/唐僧.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 唐僧 + other_names: + - 唐玄奘 + - 唐三藏 + - 玄奘 + relationships: + description: + links: \ No newline at end of file diff --git a/data/character/地藏王菩萨.yaml b/data/character/地藏王菩萨.yaml new file mode 100644 index 0000000..0bbff82 --- /dev/null +++ b/data/character/地藏王菩萨.yaml @@ -0,0 +1,13 @@ +schema: v1 +type: character +details: + name: 地藏王菩萨 + other_names: + - 幽冥教主 + relationships: + - source: 玉皇大帝 + destination: 地藏王菩萨 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/太白金星.yaml b/data/character/太白金星.yaml new file mode 100644 index 0000000..c875f11 --- /dev/null +++ b/data/character/太白金星.yaml @@ -0,0 +1,17 @@ +schema: v1 +type: character +details: + name: 太白金星 + other_names: + - 太白长庚星 + relationships: + - source: 太白金星 + destination: 孙悟空 + description: 招抚 + bidirection: true + - source: 玉皇大帝 + destination: 太白金星 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/孙悟空.yaml b/data/character/孙悟空.yaml new file mode 100644 index 0000000..cb91ea8 --- /dev/null +++ b/data/character/孙悟空.yaml @@ -0,0 +1,15 @@ +schema: v1 +type: character +details: + name: 孙悟空 + other_names: + - 孙行者 + - 者行孙 + relationships: + - source: 孙悟空 + destination: 唐僧 + description: 师徒 + bidirection: true + description: 孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。 + links: + - https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA \ No newline at end of file diff --git a/data/character/敖广.yaml b/data/character/敖广.yaml new file mode 100644 index 0000000..5a46c74 --- /dev/null +++ b/data/character/敖广.yaml @@ -0,0 +1,18 @@ +schema: v1 +type: character +details: + name: 敖广 + other_names: + - 东海龙王 + - 水元下界东胜神洲东海小龙 + relationships: + - source: 敖广 + destination: 孙悟空 + description: 邻居 + bidirection: true + - source: 玉皇大帝 + destination: 敖广 + description: 首领 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/敖钦.yaml b/data/character/敖钦.yaml new file mode 100644 index 0000000..99902f2 --- /dev/null +++ b/data/character/敖钦.yaml @@ -0,0 +1,13 @@ +schema: v1 +type: character +details: + name: 敖钦 + other_names: + - 南海龙王 + relationships: + - source: 玉皇大帝 + destination: 敖钦 + description: 首领 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/敖闰.yaml b/data/character/敖闰.yaml new file mode 100644 index 0000000..80059b0 --- /dev/null +++ b/data/character/敖闰.yaml @@ -0,0 +1,13 @@ +schema: v1 +type: character +details: + name: 敖闰 + other_names: + - 西海龙王 + relationships: + - source: 玉皇大帝 + destination: 敖闰 + description: 首领 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/敖顺.yaml b/data/character/敖顺.yaml new file mode 100644 index 0000000..b7d3380 --- /dev/null +++ b/data/character/敖顺.yaml @@ -0,0 +1,13 @@ +schema: v1 +type: character +details: + name: 敖顺 + other_names: + - 北海龙王 + relationships: + - source: 玉皇大帝 + destination: 敖顺 + description: 首领 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/文曲星官.yaml b/data/character/文曲星官.yaml new file mode 100644 index 0000000..70603fa --- /dev/null +++ b/data/character/文曲星官.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 文曲星官 + relationships: + - source: 玉皇大帝 + destination: 文曲星官 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/沙僧.yaml b/data/character/沙僧.yaml new file mode 100644 index 0000000..e28776e --- /dev/null +++ b/data/character/沙僧.yaml @@ -0,0 +1,15 @@ +schema: v1 +type: character +details: + name: 沙僧 + other_names: + - 沙悟净 + - 沙和尚 + relationships: + - source: 沙僧 + destination: 唐僧 + description: 师徒 + bidirection: true + description: + links: + - https://zh.wikipedia.org/wiki/%E6%B2%99%E5%83%A7 \ No newline at end of file diff --git a/data/character/混世魔王.yaml b/data/character/混世魔王.yaml new file mode 100644 index 0000000..65384ec --- /dev/null +++ b/data/character/混世魔王.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 混世魔王 + relationships: + - source: 孙悟空 + destination: 混世魔王 + description: 打死 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/灵台方寸山樵夫.yaml b/data/character/灵台方寸山樵夫.yaml new file mode 100644 index 0000000..79dd56f --- /dev/null +++ b/data/character/灵台方寸山樵夫.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 灵台方寸山樵夫 + relationships: + - source: 菩提祖师 + destination: 灵台方寸山樵夫 + description: 邻居 + bidirection: true + description: + links: \ No newline at end of file diff --git a/data/character/牛魔王.yaml b/data/character/牛魔王.yaml new file mode 100644 index 0000000..07e2c40 --- /dev/null +++ b/data/character/牛魔王.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 牛魔王 + relationships: + - source: 牛魔王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + description: + links: \ No newline at end of file diff --git a/data/character/猪八戒.yaml b/data/character/猪八戒.yaml new file mode 100644 index 0000000..78b5a2b --- /dev/null +++ b/data/character/猪八戒.yaml @@ -0,0 +1,13 @@ +schema: v1 +type: character +details: + name: 猪八戒 + other_names: + - 猪悟能 + relationships: + - source: 猪八戒 + destination: 唐僧 + description: 师徒 + bidirection: true + description: + links: \ No newline at end of file diff --git a/data/character/玉皇大帝.yaml b/data/character/玉皇大帝.yaml new file mode 100644 index 0000000..7af1b0d --- /dev/null +++ b/data/character/玉皇大帝.yaml @@ -0,0 +1,8 @@ +schema: v1 +type: character +details: + name: 玉皇大帝 + other_names: + - 高天上圣大慈仁者玉皇大天尊玄穹高上帝 + - 大天尊 + relationships: \ No newline at end of file diff --git a/data/character/白龙马.yaml b/data/character/白龙马.yaml new file mode 100644 index 0000000..2f09c09 --- /dev/null +++ b/data/character/白龙马.yaml @@ -0,0 +1,17 @@ +schema: v1 +type: character +details: + name: 白龙马 + other_names: + - 小白龙 + relationships: + - source: 白龙马 + destination: 唐僧 + description: 师徒 + bidirection: true + - source: 敖闰 + destination: 白龙马 + description: 三子 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/character/菩提祖师.yaml b/data/character/菩提祖师.yaml new file mode 100644 index 0000000..f96ce07 --- /dev/null +++ b/data/character/菩提祖师.yaml @@ -0,0 +1,11 @@ +schema: v1 +type: character +details: + name: 菩提祖师 + relationships: + - source: 菩提祖师 + destination: 孙悟空 + description: 师徒 + bidirection: true + description: + links: \ No newline at end of file diff --git a/data/character/葛仙翁天师.yaml b/data/character/葛仙翁天师.yaml new file mode 100644 index 0000000..4b94765 --- /dev/null +++ b/data/character/葛仙翁天师.yaml @@ -0,0 +1,12 @@ +schema: v1 +type: character +details: + name: 葛仙翁天师 + other_names: + relationships: + - source: 玉皇大帝 + destination: 葛仙翁天师 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/group/十代冥王.yaml b/data/group/十代冥王.yaml new file mode 100644 index 0000000..929a15f --- /dev/null +++ b/data/group/十代冥王.yaml @@ -0,0 +1,60 @@ +schema: v1 +type: group +details: + name: 十代冥王 + other_names: + - 冥司 + members: + - 秦广王 + - 初江王 + - 宋帝王 + - 忤官王 + - 阎罗王 + - 平等王 + - 泰山王 + - 都市王 + - 卞城王 + - 转轮王 + relationships: + - source: 地藏王菩萨 + destination: 秦广王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 初江王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 宋帝王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 忤官王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 阎罗王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 平等王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 泰山王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 都市王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 卞城王 + description: 下级 + bidirection: false + - source: 地藏王菩萨 + destination: 转轮王 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/group/天庭.yaml b/data/group/天庭.yaml new file mode 100644 index 0000000..b943a1f --- /dev/null +++ b/data/group/天庭.yaml @@ -0,0 +1,20 @@ +schema: v1 +type: group +details: + name: 天庭 + members: + - 千里眼 + - 顺风耳 + - 太白金星 + - 文曲星官 + relationships: + - source: 玉皇大帝 + destination: 千里眼 + description: 下级 + bidirection: false + - source: 玉皇大帝 + destination: 顺风耳 + description: 下级 + bidirection: false + description: + links: \ No newline at end of file diff --git a/data/group/花果山群妖.yaml b/data/group/花果山群妖.yaml new file mode 100644 index 0000000..083c39d --- /dev/null +++ b/data/group/花果山群妖.yaml @@ -0,0 +1,38 @@ +schema: v1 +type: group +details: + name: 花果山群妖 + members: + - 牛魔王 + - 蛟魔王 + - 鹏魔王 + - 狮狔王 + - 猕猴王 + - 狨王 + relationships: + - source: 孙悟空 + destination: 牛魔王 + description: 兄弟 + bidirection: false + - source: 蛟魔王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + - source: 鹏魔王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + - source: 狮狔王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + - source: 猕猴王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + - source: 狨王 + destination: 孙悟空 + description: 兄弟 + bidirection: true + description: + links: \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0e42de5 --- /dev/null +++ b/go.mod @@ -0,0 +1,25 @@ +module xyj-figures + +go 1.22.2 + +require ( + github.com/flopp/go-findfont v0.1.0 + github.com/go-test/deep v1.1.1 + github.com/goccy/go-graphviz v0.1.3 + github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 + github.com/sirupsen/logrus v1.9.3 + github.com/spf13/cobra v1.8.1 + github.com/stretchr/testify v1.7.0 + golang.org/x/image v0.14.0 + gopkg.in/yaml.v3 v3.0.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/fogleman/gg v1.3.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/spf13/pflag v1.0.5 // indirect + golang.org/x/sys v0.6.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..99524f7 --- /dev/null +++ b/go.sum @@ -0,0 +1,44 @@ +github.com/corona10/goimagehash v1.0.2 h1:pUfB0LnsJASMPGEZLj7tGY251vF+qLGqOgEP4rUs6kA= +github.com/corona10/goimagehash v1.0.2/go.mod h1:/l9umBhvcHQXVtQO1V6Gp1yD20STawkhRnnX0D1bvVI= +github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/flopp/go-findfont v0.1.0 h1:lPn0BymDUtJo+ZkV01VS3661HL6F4qFlkhcJN55u6mU= +github.com/flopp/go-findfont v0.1.0/go.mod h1:wKKxRDjD024Rh7VMwoU90i6ikQRCr+JTHB5n4Ejkqvw= +github.com/fogleman/gg v1.3.0 h1:/7zJX8F6AaYQc57WQCyN9cAIz+4bCJGO9B+dyW29am8= +github.com/fogleman/gg v1.3.0/go.mod h1:R/bRT+9gY/C5z7JzPU0zXsXHKM4/ayA+zqcVNZzPa1k= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-graphviz v0.1.3 h1:Pkt8y4FBnBNI9tfSobpoN5qy1qMNqRXPQYvLhaSUasY= +github.com/goccy/go-graphviz v0.1.3/go.mod h1:pMYpbAqJT10V8dzV1JN/g/wUlG/0imKPzn3ZsrchGCI= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 h1:DACJavvAHhabrF08vX0COfcOBJRhZ8lUbR+ZWIs0Y5g= +github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0/go.mod h1:E/TSTwGwJL78qG/PmXZO1EjYhfJinVAhrmmHX6Z8B9k= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5 h1:BvoENQQU+fZ9uukda/RzCAL/191HHwJA5b13R6diVlY= +github.com/nfnt/resize v0.0.0-20160724205520-891127d8d1b5/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= +github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= +github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4= +golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/batch/batch.go b/pkg/batch/batch.go new file mode 100644 index 0000000..e5db10b --- /dev/null +++ b/pkg/batch/batch.go @@ -0,0 +1,225 @@ +package batch + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "xyj-figures/pkg/entity" + "xyj-figures/pkg/relationship" + + findfont "github.com/flopp/go-findfont" + graphviz "github.com/goccy/go-graphviz" + "github.com/goccy/go-graphviz/cgraph" + "github.com/golang/freetype/truetype" + log "github.com/sirupsen/logrus" + "golang.org/x/image/font" +) + +const ( + CLUSTER_PREFIX = "cluster_" +) + +func LoadFolder(path string) ([]entity.Entity, []relationship.Relationship, error) { + es := []entity.Entity{} + rs := []relationship.Relationship{} + errs := []string{} + + // Triverse all files in path and load all entities and relationships + err := filepath.Walk(path, func(subpath string, info os.FileInfo, err error) error { + if err != nil { + errs = append(errs, err.Error()) + return nil + } + if !info.IsDir() { + e, err := entity.Load(subpath) + if err != nil { + errs = append(errs, err.Error()) + return nil + } + es = append(es, *e) + rs = append(rs, e.Details.(entity.EntityDetails).GetRelationships()...) + } + return nil + }) + if len(errs) > 0 { + return es, rs, fmt.Errorf("multiple errors: %v", strings.Join(errs, "; ")) + } + return es, rs, err +} + +// DistillData takes loaded data and do all needed optimizations and extract only needed info for maps +func DistillData(es []entity.Entity, rs []relationship.Relationship) ([]entity.Entity, []relationship.Relationship, error) { + // Create a set of all entities, including characters and every members + // TODO: Duplicated name + nodes := map[string]entity.Entity{} + edges := map[string]relationship.Relationship{} + for _, e := range es { + switch e.Type { + case entity.EntityTypeCharacter: + if val, ok := nodes[e.Details.(entity.Character).Name]; ok { + if err := val.Merge(e); err != nil { + log.Warnf("failed to merge: %v", err) + } + continue + } + nodes[e.Details.(entity.Character).Name] = e + case entity.EntityTypeGroup: + nodes[e.Details.(entity.Group).Name] = e + for _, m := range e.Details.(entity.Group).Members { + nodes[m] = entity.Entity{ + Schema: "v1", + Type: entity.EntityTypeCharacter, + Details: entity.Character{ + Name: m, + }, + } + } + } + } + + for _, r := range rs { + key1, key2 := r.GetKey() + val1, ok1 := edges[key1] + val2, ok2 := edges[key2] + // Both keys don't exist + if !ok1 && !ok2 { + edges[key1] = r + continue + } + // Logs all unexpected edges + if ok1 && !r.IsSame(val1) { + log.Warningf("Found ambigious edges, existing: %+v, new: %+v", val1, r) + } + if ok2 && !r.IsSame(val2) { + log.Warningf("Found ambigious edges, existing: %+v, new: %+v", val2, r) + } + } + + retns := make([]entity.Entity, 0, len(nodes)) + for _, n := range nodes { + retns = append(retns, n) + } + retes := make([]relationship.Relationship, 0, len(edges)) + for _, v := range edges { + retes = append(retes, v) + } + + return retns, retes, nil +} + +// Save distilled info into DOT type files +func Save(path string, ents []entity.Entity, rels []relationship.Relationship) error { + g := graphviz.New() + g.SetLayout(graphviz.TWOPI) + useSystemFont(g, "SimSum.ttc") + rootgraph, err := g.Graph() + rootgraph.SafeSet("fontname", "NSimSun", "Times-Roman") + if err != nil { + return fmt.Errorf("failed to create graph: %v", err) + } + defer func() { + if err := rootgraph.Close(); err != nil { + log.Errorf("failed to close graph: %v", err) + } + g.Close() + }() + + nodesPtr := map[string]*cgraph.Node{} + subGraphPtr := map[string]*cgraph.Graph{} + nodeGraphMap := map[string]string{} + for _, e := range ents { + if e.Type == entity.EntityTypeGroup { + subGraph := rootgraph.SubGraph(CLUSTER_PREFIX+e.Details.(entity.Group).Name, 1) + subGraph.SetLabel(e.Details.(entity.Group).Name) + // subGraph.SafeSet("fontname", "NSimSun", "Times-Roman") + subGraphPtr[e.Details.(entity.Group).Name] = subGraph + // Record all members + for _, m := range e.Details.(entity.Group).Members { + nodeGraphMap[m] = e.Details.(entity.Group).Name + } + } + } + fmt.Println(subGraphPtr) + fmt.Println(nodeGraphMap) + + for _, e := range ents { + if e.Type == entity.EntityTypeCharacter { + log.Debugf("entity: %+v", e) + if group, ok := nodeGraphMap[e.Details.(entity.Character).Name]; ok { + ptr, ok := subGraphPtr[group] + if !ok { + log.Debugf("can't find sub graph %s", group) + continue + } + if ptr == nil { + log.Debugf("sub graph %s is nil", group) + continue + } + n, err := ptr.CreateNode(e.Details.(entity.Character).Name) + if err != nil { + log.Errorf("failed to create node in sub graph %s: %v", group, err) + continue + } + nodesPtr[e.Details.(entity.Character).Name] = n + continue + } + node, err := rootgraph.CreateNode(e.Details.(entity.Character).Name) + if err != nil { + log.Errorf("failed to create node: %v", err) + continue + } + nodesPtr[e.Details.(entity.Character).Name] = node + continue + } + } + fmt.Println(nodesPtr) + + for _, r := range rels { + key, _ := r.GetKey() + srcPtr := nodesPtr[r.Src] + dstPtr := nodesPtr[r.Dst] + fmt.Printf("src: %s, dst: %s\n", r.Src, r.Dst) + e, err := rootgraph.CreateEdge(key, srcPtr, dstPtr) + if err != nil { + log.Errorf("failed to create edge: %v", err) + continue + } + e.SetLabel(r.Description) + e.SafeSet("fontname", "NSimSun", "Times-Roman") + e.SafeSet("fontsize", "12", "10") + if r.Bidirection { + e.SetDir(cgraph.NoneDir) + } + } + if err := g.RenderFilename(rootgraph, "dot", path); err != nil { + log.Fatal(err) + } + + if err := g.RenderFilename(rootgraph, graphviz.PNG, path+".png"); err != nil { + return fmt.Errorf("failed to save graph to disk: %v", err) + } + return nil +} + +func useSystemFont(g *graphviz.Graphviz, name string) { + fontPath, err := findfont.Find(name) + if err != nil { + panic(err) + } + + fontData, err := os.ReadFile(fontPath) + if err != nil { + panic(err) + } + ft, err := truetype.Parse(fontData) + if err != nil { + panic(err) + } + g.SetFontFace(func(size float64) (font.Face, error) { + opt := &truetype.Options{ + Size: size, + } + return truetype.NewFace(ft, opt), nil + }) +} diff --git a/pkg/batch/batch_test.go b/pkg/batch/batch_test.go new file mode 100644 index 0000000..6df7657 --- /dev/null +++ b/pkg/batch/batch_test.go @@ -0,0 +1,103 @@ +package batch_test + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "xyj-figures/pkg/batch" + "xyj-figures/pkg/entity" + "xyj-figures/pkg/relationship" + + "github.com/go-test/deep" +) + +const ( + DataFolder = "../../data" +) + +func TestLoadFolder(t *testing.T) { + tcs := []struct { + name string + filelist []string + want struct { + entities []entity.Entity + relationships []relationship.Relationship + errors error + } + wanterr bool + }{ + { + name: "test1", + filelist: []string{ + "character/孙悟空.yaml", + }, + want: struct { + entities []entity.Entity + relationships []relationship.Relationship + errors error + }{ + entities: []entity.Entity{ + { + Schema: "v1", + Type: entity.EntityTypeCharacter, + Details: entity.Character{ + Name: "孙悟空", + OtherNames: []string{"孙行者", "者行孙"}, + Relationships: []relationship.Relationship{ + { + Src: "孙悟空", + Dst: "唐僧", + Description: "师徒", + Bidirection: true, + }, + }, + Description: "孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。", + Links: []string{ + "https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA", + }, + }, + }, + }, + relationships: []relationship.Relationship{ + { + Src: "孙悟空", + Dst: "唐僧", + Description: "师徒", + Bidirection: true, + }, + }, + }, + wanterr: false, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + tmpDir := t.TempDir() + for _, fn := range tc.filelist { + f, err := os.ReadFile(filepath.Join(DataFolder, fn)) + if err != nil { + t.Fatalf("failed to copy file %s: %v", fn, err) + } + dstPath := filepath.Join(tmpDir, fn) + if err := os.MkdirAll(filepath.Dir(dstPath), os.ModePerm); err != nil { + t.Fatalf("failed to copy file %s: %v", fn, err) + } + if err := os.WriteFile(dstPath, f, 0644); err != nil { + t.Fatalf("failed to copy file %s: %v", fn, err) + } + } + egot, rgot, errgot := batch.LoadFolder(tmpDir) + if (!tc.wanterr && errgot != nil) || (tc.wanterr && errgot == nil) { + t.Errorf("error not expected, wanterr: %v got: %+v", tc.wanterr, errgot) + } + if !reflect.DeepEqual(egot, tc.want.entities) { + t.Errorf("entities diff: %+v", deep.Equal(egot, tc.want.entities)) + } + if !reflect.DeepEqual(rgot, tc.want.relationships) { + t.Errorf("relationship diff: %+v", deep.Equal(rgot, tc.want.relationships)) + + } + }) + } +} diff --git a/pkg/entity/character.go b/pkg/entity/character.go new file mode 100644 index 0000000..5b49f3b --- /dev/null +++ b/pkg/entity/character.go @@ -0,0 +1,17 @@ +package entity + +import ( + r "xyj-figures/pkg/relationship" +) + +type Character struct { + Name string `yaml:"name"` + OtherNames []string `yaml:"other_names,flow"` + Relationships []r.Relationship `yaml:"relationships,flow"` + Description string `yaml:"description"` + Links []string `yaml:"links,flow"` +} + +func (c Character) GetRelationships() []r.Relationship { + return c.Relationships +} diff --git a/pkg/entity/entity.go b/pkg/entity/entity.go new file mode 100644 index 0000000..2c4eae0 --- /dev/null +++ b/pkg/entity/entity.go @@ -0,0 +1,94 @@ +package entity + +import ( + "fmt" + "os" + + r "xyj-figures/pkg/relationship" + + yaml "gopkg.in/yaml.v3" +) + +type Entity struct { + Schema string `yaml:"schema,flow"` + Type string `yaml:"type,flow"` + Details interface{} `yaml:"details,flow"` +} + +type EntityType string + +const ( + EntityTypeCharacter = "character" + EntityTypeGroup = "group" +) + +type EntityDetails interface { + GetRelationships() []r.Relationship +} + +func NewEntity() (*Entity, error) { + return &Entity{Schema: "v1"}, nil +} + +func Load(path string) (*Entity, error) { + f, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to open file: %v", err) + } + e, _ := NewEntity() + if err = yaml.Unmarshal(f, &e); err != nil { + return nil, fmt.Errorf("failed to unmarshal file: %v", err) + } + switch e.Type { + case EntityTypeCharacter: + tmp, err := yaml.Marshal(e.Details) + if err != nil { + return nil, fmt.Errorf("failed to marshal filed %s: %v", EntityTypeCharacter, err) + } + c := Character{} + if err = yaml.Unmarshal(tmp, &c); err != nil { + return nil, fmt.Errorf("failed to unmarshal filed %s: %v", EntityTypeCharacter, err) + } + e.Details = c + case EntityTypeGroup: + tmp, err := yaml.Marshal(e.Details) + if err != nil { + return nil, fmt.Errorf("failed to marshal filed %s: %v", EntityTypeGroup, err) + } + g := Group{} + if err = yaml.Unmarshal(tmp, &g); err != nil { + return nil, fmt.Errorf("failed to unmarshal filed %s: %v", EntityTypeGroup, err) + } + e.Details = g + } + return e, nil +} + +func (e Entity) Merge(other Entity) error { + if (e.Schema != other.Schema) || (e.Type != other.Type) { + return fmt.Errorf("unmatch merge between %v and %v", e, other) + } + switch e.Type { + case EntityTypeCharacter: + if e.Details.(Character).Name != other.Details.(Character).Name { + return fmt.Errorf("unmatch merge between %v and %v: names unmatch", e, other) + } + e.Details = Character{ + Name: e.Details.(Character).Name, + OtherNames: append(e.Details.(Character).OtherNames, other.Details.(Character).OtherNames...), + Relationships: append(e.Details.(Character).Relationships, other.Details.(Character).Relationships...), + Description: fmt.Sprintf("%s%s", e.Details.(Character).Description, other.Details.(Character).Description), + Links: append(e.Details.(Character).Links, other.Details.(Character).Links...), + } + case EntityTypeGroup: + e.Details = Group{ + Name: e.Details.(Group).Name, + OtherNames: append(e.Details.(Group).OtherNames, other.Details.(Group).OtherNames...), + Members: append(e.Details.(Group).Members, other.Details.(Group).Members...), + Relationships: append(e.Details.(Group).Relationships, other.Details.(Group).Relationships...), + Description: fmt.Sprintf("%s%s", e.Details.(Group).Description, other.Details.(Group).Description), + Links: append(e.Details.(Group).Links, other.Details.(Group).Links...), + } + } + return nil +} diff --git a/pkg/entity/entity_test.go b/pkg/entity/entity_test.go new file mode 100644 index 0000000..4bcb901 --- /dev/null +++ b/pkg/entity/entity_test.go @@ -0,0 +1,123 @@ +package entity_test + +import ( + "log" + "os" + "path/filepath" + "reflect" + "testing" + "xyj-figures/pkg/entity" + "xyj-figures/pkg/relationship" + + "github.com/go-test/deep" +) + +func TestLoad(t *testing.T) { + tmpDir := os.TempDir() + tcs := []struct { + name string + payload string + want entity.Entity + wanterr bool + err error + }{ + { + name: "Valid Character", + payload: `schema: v1 +type: character +details: + name: 孙悟空 + other_names: + - 孙行者 + - 者行孙 + relationships: + - source: 孙悟空 + destination: 唐三藏 + description: 师徒 + bidirection: true + description: 孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。 + links: + - https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA +`, + want: entity.Entity{ + Schema: "v1", + Type: entity.EntityTypeCharacter, + Details: entity.Character{ + Name: "孙悟空", + OtherNames: []string{"孙行者", "者行孙"}, + Relationships: []relationship.Relationship{ + { + Src: "孙悟空", + Dst: "唐三藏", + Description: "师徒", + Bidirection: true, + }, + }, + Description: "孙悟空是明朝中后期畅销小说《西游记》当中个第一主角,是从石头里向蹦出来个猢狲。", + Links: []string{ + "https://zh.wikipedia.org/wiki/%E5%AD%99%E6%82%9F%E7%A9%BA", + }, + }, + }, + wanterr: false, + }, + { + name: "Valid Group", + payload: `schema: v1 +type: group +details: + name: 唐僧师徒 + other_names: + - 取经团队 + members: + - 孙悟空 + - 猪八戒 + - 沙僧 + - 唐僧 + - 白龙马 + links: + - https://zh.wikipedia.org/wiki/%E8%A5%BF%E6%B8%B8%E8%AE%B0%E8%A7%92%E8%89%B2%E5%88%97%E8%A1%A8#%E5%94%90%E5%83%A7%E5%B8%AB%E5%BE%92 +`, + want: entity.Entity{ + Schema: "v1", + Type: entity.EntityTypeGroup, + Details: entity.Group{ + Name: "唐僧师徒", + OtherNames: []string{"取经团队"}, + Members: []string{"孙悟空", "猪八戒", "沙僧", "唐僧", "白龙马"}, + Links: []string{ + "https://zh.wikipedia.org/wiki/%E8%A5%BF%E6%B8%B8%E8%AE%B0%E8%A7%92%E8%89%B2%E5%88%97%E8%A1%A8#%E5%94%90%E5%83%A7%E5%B8%AB%E5%BE%92", + }, + }, + }, + wanterr: false, + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + // Prepare data + tmpFile, err := os.Create(filepath.Join(tmpDir, tc.name)) + if err != nil { + t.Fatalf("Failed to create temporary file: %v", err) + } + defer tmpFile.Close() + + if _, err = tmpFile.Write([]byte(tc.payload)); err != nil { + t.Fatalf("Failed to write to temporary file: %v", err) + } + + // Call the function and assert the results + e, err := entity.Load(tmpFile.Name()) + if err != nil && !tc.wanterr { + t.Errorf("Unexpected error: %v", err) + } + if err == nil && tc.wanterr { + t.Errorf("expected error but not get") + } + // Sanity Check + if !reflect.DeepEqual(*e, tc.want) { + log.Fatalf("Load got %+v want %+v, diff %+v", *e, tc.want, deep.Equal(*e, tc.want)) + } + }) + } +} diff --git a/pkg/entity/group.go b/pkg/entity/group.go new file mode 100644 index 0000000..581e655 --- /dev/null +++ b/pkg/entity/group.go @@ -0,0 +1,16 @@ +package entity + +import r "xyj-figures/pkg/relationship" + +type Group struct { + Name string + OtherNames []string `yaml:"other_names,flow"` + Members []string `yaml:"members,flow"` + Relationships []r.Relationship `yaml:"relationships,flow"` + Description string `yaml:"description"` + Links []string `yaml:"links,flow"` +} + +func (g Group) GetRelationships() []r.Relationship { + return g.Relationships +} diff --git a/pkg/relationship/relationship.go b/pkg/relationship/relationship.go new file mode 100644 index 0000000..568e882 --- /dev/null +++ b/pkg/relationship/relationship.go @@ -0,0 +1,24 @@ +package relationship + +import "fmt" + +type Relationship struct { + Src string `yaml:"source"` + Dst string `yaml:"destination"` + Description string `yaml:"description"` + Bidirection bool `yaml:"bidirection"` +} + +func (r Relationship) IsSame(other Relationship) bool { + if r.Bidirection { + if r.Description != other.Description { + return false + } + return (r.Src == other.Src && r.Dst == other.Dst) || (r.Src == other.Dst && r.Dst == other.Src) + } + return r.Src == other.Src && r.Dst == other.Dst && r.Description == other.Description +} + +func (r Relationship) GetKey() (string, string) { + return fmt.Sprintf("%s-%s", r.Src, r.Dst), fmt.Sprintf("%s-%s", r.Dst, r.Src) +} diff --git a/pkg/relationship/relationship_test.go b/pkg/relationship/relationship_test.go new file mode 100644 index 0000000..b006304 --- /dev/null +++ b/pkg/relationship/relationship_test.go @@ -0,0 +1,120 @@ +package relationship_test + +import ( + "testing" + r "xyj-figures/pkg/relationship" + + "github.com/stretchr/testify/assert" +) + +func TestRelationship_IsSame(t *testing.T) { + tests := []struct { + name string + r r.Relationship + other r.Relationship + expected bool + }{ + { + name: "Bidirectional relationships with same source and destination", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: true, + }, + other: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: true, + }, + expected: true, + }, + { + name: "Bidirectional relationships with swapped source and destination", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: true, + }, + other: r.Relationship{ + Src: "B", + Dst: "A", + Description: "friend", + Bidirection: true, + }, + expected: true, + }, + { + name: "Bidirectional relationships with different descriptions", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: true, + }, + other: r.Relationship{ + Src: "A", + Dst: "B", + Description: "enemy", + Bidirection: true, + }, + expected: false, + }, + { + name: "Unidirectional relationships with same source and destination", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: false, + }, + other: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: false, + }, + expected: true, + }, + { + name: "Unidirectional relationships with swapped source and destination", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: false, + }, + other: r.Relationship{ + Src: "B", + Dst: "A", + Description: "friend", + Bidirection: false, + }, + expected: false, + }, + { + name: "Unidirectional relationships with different descriptions", + r: r.Relationship{ + Src: "A", + Dst: "B", + Description: "friend", + Bidirection: false, + }, + other: r.Relationship{ + Src: "A", + Dst: "B", + Description: "enemy", + Bidirection: false, + }, + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.expected, tt.r.IsSame(tt.other)) + }) + } +}