diff --git a/cmd/photoprism/photoprism.go b/cmd/photoprism/photoprism.go index 512add849..8efd1f9f0 100644 --- a/cmd/photoprism/photoprism.go +++ b/cmd/photoprism/photoprism.go @@ -56,31 +56,7 @@ func main() { app.Copyright = appCopyright app.EnableBashCompletion = true app.Flags = config.GlobalFlags - - app.Commands = []cli.Command{ - commands.StartCommand, - commands.StopCommand, - commands.StatusCommand, - commands.IndexCommand, - commands.ImportCommand, - commands.CopyCommand, - commands.FacesCommand, - commands.PlacesCommand, - commands.PurgeCommand, - commands.CleanUpCommand, - commands.OptimizeCommand, - commands.MomentsCommand, - commands.ConvertCommand, - commands.ThumbsCommand, - commands.MigrationsCommand, - commands.BackupCommand, - commands.RestoreCommand, - commands.ResetCommand, - commands.PasswdCommand, - commands.UsersCommand, - commands.ShowCommand, - commands.VersionCommand, - } + app.Commands = commands.PhotoPrism if err := app.Run(os.Args); err != nil { log.Error(err) diff --git a/frontend/src/common/util.js b/frontend/src/common/util.js index 6bde21547..0992c82a8 100644 --- a/frontend/src/common/util.js +++ b/frontend/src/common/util.js @@ -151,10 +151,15 @@ export default class Util { return "Advanced Video Coding (AVC) / H.264"; case "hvc1": return "High Efficiency Video Coding (HEVC) / H.265"; + case "av01": + return "AOMedia Video 1 (AV1)"; + case "mpeg": + return "Moving Picture Experts Group (MPEG)"; case "mjpg": - return "Motion JPEG (MJPEG)"; + return "Motion JPEG (M-JPEG)"; + case "heif": case "heic": - return "High Efficiency Image File Format (HEIC)"; + return "High Efficiency Image File Format (HEIF)"; case "1": return "Uncompressed"; case "2": diff --git a/go.mod b/go.mod index 99f3b1c31..6dd4186a9 100644 --- a/go.mod +++ b/go.mod @@ -80,8 +80,11 @@ require ( github.com/gosimple/unidecode v1.0.1 // indirect github.com/leodido/go-urn v1.2.1 // indirect github.com/mandykoh/go-parallel v0.1.0 // indirect + github.com/mattn/go-runewidth v0.0.13 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/olekukonko/tablewriter v0.0.5 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/tidwall/match v1.1.1 // indirect github.com/tidwall/pretty v1.2.0 // indirect diff --git a/go.sum b/go.sum index 06aee777d..31368c39c 100644 --- a/go.sum +++ b/go.sum @@ -233,7 +233,10 @@ github.com/matryer/is v1.2.0/go.mod h1:2fLPjFQM9rhQ15aVEtbuwhJinnOqrmgXPNdZsdwlW github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= +github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4OSgU= +github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= github.com/mattn/go-sqlite3 v2.0.1+incompatible h1:xQ15muvnzGBHpIpdrNi1DA5x0+TcBZzsIDwmw9uTHzw= github.com/mattn/go-sqlite3 v2.0.1+incompatible/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -245,6 +248,8 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/montanaflynn/stats v0.6.6 h1:Duep6KMIDpY4Yo11iFsvyqJDyfzLF9+sndUKT+v64GQ= github.com/montanaflynn/stats v0.6.6/go.mod h1:etXPPgVO6n31NxCd9KQUMvCM+ve0ruNzt6R8Bnaayow= +github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= github.com/paulmach/go.geojson v1.4.0 h1:5x5moCkCtDo5x8af62P9IOAYGQcYHtxz2QJ3x1DoCgY= @@ -261,6 +266,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0 h1:FCbCCtXNOY3UtUuHUYaghJg4y7Fd14rXifAYUAtL9R8= diff --git a/internal/commands/commands.go b/internal/commands/commands.go index 4e5ddbe23..d4d1d4a57 100644 --- a/internal/commands/commands.go +++ b/internal/commands/commands.go @@ -1,9 +1,28 @@ /* -Package commands contains commands and flags used by the photoprism application. -Additional information concerning the command-line interface can be found in our Developer Guide: +Package commands provides photoprism CLI (sub-)commands. + +Copyright (c) 2018 - 2022 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an e-mail to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + -https://github.com/photoprism/photoprism/wiki/Commands */ package commands @@ -11,13 +30,41 @@ import ( "os" "syscall" + "github.com/sevlyar/go-daemon" + "github.com/urfave/cli" + "github.com/photoprism/photoprism/internal/event" "github.com/photoprism/photoprism/pkg/fs" - "github.com/sevlyar/go-daemon" ) var log = event.Log +// PhotoPrism contains the photoprism CLI (sub-)commands. +var PhotoPrism = []cli.Command{ + StartCommand, + StopCommand, + StatusCommand, + IndexCommand, + ImportCommand, + CopyCommand, + FacesCommand, + PlacesCommand, + PurgeCommand, + CleanUpCommand, + OptimizeCommand, + MomentsCommand, + ConvertCommand, + ThumbsCommand, + MigrationsCommand, + BackupCommand, + RestoreCommand, + ResetCommand, + PasswdCommand, + UsersCommand, + ShowCommand, + VersionCommand, +} + // childAlreadyRunning tests if a .pid file at filePath is a running process. // it returns the pid value and the running status (true or false). func childAlreadyRunning(filePath string) (pid int, running bool) { diff --git a/internal/commands/index_test.go b/internal/commands/index_test.go index 414ef78eb..744142cc2 100644 --- a/internal/commands/index_test.go +++ b/internal/commands/index_test.go @@ -48,7 +48,8 @@ func TestIndexCommand(t *testing.T) { // Expected index command output. assert.Contains(t, output, "indexing originals") assert.Contains(t, output, "classify: loading") - assert.Contains(t, output, "indexed 0 files") + assert.Contains(t, output, "indexed") + assert.Contains(t, output, "files") } else { t.Fatal("log output missing") } diff --git a/internal/commands/show_config.go b/internal/commands/show_config.go index d1c269c00..575e91bda 100644 --- a/internal/commands/show_config.go +++ b/internal/commands/show_config.go @@ -2,174 +2,34 @@ package commands import ( "fmt" - "strings" - "time" - "unicode/utf8" + "github.com/sirupsen/logrus" "github.com/urfave/cli" "github.com/photoprism/photoprism/internal/config" + "github.com/photoprism/photoprism/pkg/report" ) var ShowConfigCommand = cli.Command{ - Name: "config", - Usage: "Displays global configuration values", + Name: "config", + Usage: "Displays global configuration values", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "no-wrap, n", + Usage: "disable text-wrapping", + }, + }, Action: showConfigAction, } // showConfigAction lists configuration options and their values. func showConfigAction(ctx *cli.Context) error { conf := config.NewConfig(ctx) + conf.SetLogLevel(logrus.FatalLevel) - dbDriver := conf.DatabaseDriver() + rows, cols := conf.Table() - fmt.Printf("%-25s VALUE\n", "NAME") - - // Flags. - fmt.Printf("%-25s %t\n", "debug", conf.Debug()) - fmt.Printf("%-25s %s\n", "log-level", conf.LogLevel()) - fmt.Printf("%-25s %t\n", "public", conf.Public()) - fmt.Printf("%-25s %s\n", "admin-password", strings.Repeat("*", utf8.RuneCountInString(conf.AdminPassword()))) - fmt.Printf("%-25s %t\n", "read-only", conf.ReadOnly()) - fmt.Printf("%-25s %t\n", "experimental", conf.Experimental()) - - // Config. - fmt.Printf("%-25s %s\n", "config-file", conf.ConfigFile()) - fmt.Printf("%-25s %s\n", "config-path", conf.ConfigPath()) - fmt.Printf("%-25s %s\n", "settings-file", conf.SettingsFile()) - - // Originals. - fmt.Printf("%-25s %s\n", "originals-path", conf.OriginalsPath()) - fmt.Printf("%-25s %d\n", "originals-limit", conf.OriginalsLimit()) - fmt.Printf("%-25s %d\n", "resolution-limit", conf.ResolutionLimit()) - - // Other paths. - fmt.Printf("%-25s %s\n", "storage-path", conf.StoragePath()) - fmt.Printf("%-25s %s\n", "sidecar-path", conf.SidecarPath()) - fmt.Printf("%-25s %s\n", "cache-path", conf.CachePath()) - fmt.Printf("%-25s %s\n", "albums-path", conf.AlbumsPath()) - fmt.Printf("%-25s %s\n", "backup-path", conf.BackupPath()) - fmt.Printf("%-25s %s\n", "import-path", conf.ImportPath()) - fmt.Printf("%-25s %s\n", "assets-path", conf.AssetsPath()) - fmt.Printf("%-25s %s\n", "static-path", conf.StaticPath()) - fmt.Printf("%-25s %s\n", "build-path", conf.BuildPath()) - fmt.Printf("%-25s %s\n", "img-path", conf.ImgPath()) - fmt.Printf("%-25s %s\n", "templates-path", conf.TemplatesPath()) - fmt.Printf("%-25s %s\n", "temp-path", conf.TempPath()) - - // Workers. - fmt.Printf("%-25s %d\n", "workers", conf.Workers()) - fmt.Printf("%-25s %s\n", "wakeup-interval", conf.WakeupInterval().String()) - fmt.Printf("%-25s %d\n", "auto-index", conf.AutoIndex()/time.Second) - fmt.Printf("%-25s %d\n", "auto-import", conf.AutoImport()/time.Second) - - // Feature Flags. - fmt.Printf("%-25s %t\n", "disable-backups", conf.DisableBackups()) - fmt.Printf("%-25s %t\n", "disable-settings", conf.DisableSettings()) - fmt.Printf("%-25s %t\n", "disable-places", conf.DisablePlaces()) - fmt.Printf("%-25s %t\n", "disable-tensorflow", conf.DisableTensorFlow()) - fmt.Printf("%-25s %t\n", "disable-faces", conf.DisableFaces()) - fmt.Printf("%-25s %t\n", "disable-classification", conf.DisableClassification()) - fmt.Printf("%-25s %t\n", "disable-ffmpeg", conf.DisableFFmpeg()) - fmt.Printf("%-25s %t\n", "disable-exiftool", conf.DisableExifTool()) - fmt.Printf("%-25s %t\n", "disable-heifconvert", conf.DisableHeifConvert()) - fmt.Printf("%-25s %t\n", "disable-darktable", conf.DisableDarktable()) - fmt.Printf("%-25s %t\n", "disable-rawtherapee", conf.DisableRawtherapee()) - fmt.Printf("%-25s %t\n", "disable-sips", conf.DisableSips()) - fmt.Printf("%-25s %t\n", "disable-raw", conf.DisableRaw()) - - // Format Flags. - fmt.Printf("%-25s %t\n", "raw-presets", conf.RawPresets()) - fmt.Printf("%-25s %t\n", "exif-bruteforce", conf.ExifBruteForce()) - - // TensorFlow. - fmt.Printf("%-25s %t\n", "detect-nsfw", conf.DetectNSFW()) - fmt.Printf("%-25s %t\n", "upload-nsfw", conf.UploadNSFW()) - fmt.Printf("%-25s %s\n", "tensorflow-version", conf.TensorFlowVersion()) - fmt.Printf("%-25s %s\n", "tensorflow-model-path", conf.TensorFlowModelPath()) - - // UI Defaults. - fmt.Printf("%-25s %s\n", "default-locale", conf.DefaultLocale()) - - // Progressive Web App. - fmt.Printf("%-25s %s\n", "app-icon", conf.AppIcon()) - fmt.Printf("%-25s %s\n", "app-name", conf.AppName()) - fmt.Printf("%-25s %s\n", "app-mode", conf.AppMode()) - - // Site Infos. - fmt.Printf("%-25s %s\n", "cdn-url", conf.CdnUrl("/")) - fmt.Printf("%-25s %s\n", "site-url", conf.SiteUrl()) - fmt.Printf("%-25s %s\n", "site-author", conf.SiteAuthor()) - fmt.Printf("%-25s %s\n", "site-title", conf.SiteTitle()) - fmt.Printf("%-25s %s\n", "site-caption", conf.SiteCaption()) - fmt.Printf("%-25s %s\n", "site-description", conf.SiteDescription()) - fmt.Printf("%-25s %s\n", "site-preview", conf.SitePreview()) - - // Legal info. - fmt.Printf("%-25s %s\n", "imprint", conf.Imprint()) - fmt.Printf("%-25s %s\n", "imprint-url", conf.ImprintUrl()) - - // URIs. - fmt.Printf("%-25s %s\n", "content-uri", conf.ContentUri()) - fmt.Printf("%-25s %s\n", "static-uri", conf.StaticUri()) - fmt.Printf("%-25s %s\n", "api-uri", conf.ApiUri()) - fmt.Printf("%-25s %s\n", "base-uri", conf.BaseUri("/")) - - // Web Server. - fmt.Printf("%-25s %s\n", "http-host", conf.HttpHost()) - fmt.Printf("%-25s %d\n", "http-port", conf.HttpPort()) - fmt.Printf("%-25s %s\n", "http-mode", conf.HttpMode()) - - // Database. - fmt.Printf("%-25s %s\n", "database-driver", dbDriver) - fmt.Printf("%-25s %s\n", "database-server", conf.DatabaseServer()) - fmt.Printf("%-25s %s\n", "database-host", conf.DatabaseHost()) - fmt.Printf("%-25s %s\n", "database-port", conf.DatabasePortString()) - fmt.Printf("%-25s %s\n", "database-name", conf.DatabaseName()) - fmt.Printf("%-25s %s\n", "database-user", conf.DatabaseUser()) - fmt.Printf("%-25s %s\n", "database-password", strings.Repeat("*", utf8.RuneCountInString(conf.DatabasePassword()))) - fmt.Printf("%-25s %d\n", "database-conns", conf.DatabaseConns()) - fmt.Printf("%-25s %d\n", "database-conns-idle", conf.DatabaseConnsIdle()) - - // External Tools. - fmt.Printf("%-25s %s\n", "darktable-bin", conf.DarktableBin()) - fmt.Printf("%-25s %s\n", "darktable-cache-path", conf.DarktableCachePath()) - fmt.Printf("%-25s %s\n", "darktable-config-path", conf.DarktableConfigPath()) - fmt.Printf("%-25s %s\n", "darktable-blacklist", conf.DarktableBlacklist()) - fmt.Printf("%-25s %s\n", "rawtherapee-bin", conf.RawtherapeeBin()) - fmt.Printf("%-25s %s\n", "rawtherapee-blacklist", conf.RawtherapeeBlacklist()) - fmt.Printf("%-25s %s\n", "sips-bin", conf.SipsBin()) - fmt.Printf("%-25s %s\n", "heifconvert-bin", conf.HeifConvertBin()) - fmt.Printf("%-25s %s\n", "ffmpeg-bin", conf.FFmpegBin()) - fmt.Printf("%-25s %s\n", "ffmpeg-encoder", conf.FFmpegEncoder()) - fmt.Printf("%-25s %d\n", "ffmpeg-bitrate", conf.FFmpegBitrate()) - fmt.Printf("%-25s %s\n", "exiftool-bin", conf.ExifToolBin()) - - // Thumbnails. - fmt.Printf("%-25s %s\n", "download-token", conf.DownloadToken()) - fmt.Printf("%-25s %s\n", "preview-token", conf.PreviewToken()) - fmt.Printf("%-25s %s\n", "thumb-color", conf.ThumbColor()) - fmt.Printf("%-25s %s\n", "thumb-filter", conf.ThumbFilter()) - fmt.Printf("%-25s %d\n", "thumb-size", conf.ThumbSizePrecached()) - fmt.Printf("%-25s %d\n", "thumb-size-uncached", conf.ThumbSizeUncached()) - fmt.Printf("%-25s %t\n", "thumb-uncached", conf.ThumbUncached()) - fmt.Printf("%-25s %s\n", "thumb-path", conf.ThumbPath()) - fmt.Printf("%-25s %d\n", "jpeg-quality", conf.JpegQuality()) - fmt.Printf("%-25s %d\n", "jpeg-size", conf.JpegSize()) - - // Facial Recognition. - fmt.Printf("%-25s %d\n", "face-size", conf.FaceSize()) - fmt.Printf("%-25s %f\n", "face-score", conf.FaceScore()) - fmt.Printf("%-25s %d\n", "face-overlap", conf.FaceOverlap()) - fmt.Printf("%-25s %d\n", "face-cluster-size", conf.FaceClusterSize()) - fmt.Printf("%-25s %d\n", "face-cluster-score", conf.FaceClusterScore()) - fmt.Printf("%-25s %d\n", "face-cluster-core", conf.FaceClusterCore()) - fmt.Printf("%-25s %f\n", "face-cluster-dist", conf.FaceClusterDist()) - fmt.Printf("%-25s %f\n", "face-match-dist", conf.FaceMatchDist()) - - // Daemon Mode. - fmt.Printf("%-25s %s\n", "pid-filename", conf.PIDFilename()) - fmt.Printf("%-25s %s\n", "log-filename", conf.LogFilename()) + fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap"))) return nil } diff --git a/internal/commands/show_config_test.go b/internal/commands/show_config_test.go index 422004abb..0c4142c90 100644 --- a/internal/commands/show_config_test.go +++ b/internal/commands/show_config_test.go @@ -23,7 +23,6 @@ func TestConfigCommand(t *testing.T) { } // Expected config command output. - assert.Contains(t, output, "NAME VALUE") assert.Contains(t, output, "config-file") assert.Contains(t, output, "darktable-cli") assert.Contains(t, output, "originals-path") diff --git a/internal/commands/show_formats.go b/internal/commands/show_formats.go index 24a97d15d..4f3407b10 100644 --- a/internal/commands/show_formats.go +++ b/internal/commands/show_formats.go @@ -3,19 +3,33 @@ package commands import ( "fmt" - "github.com/photoprism/photoprism/pkg/fs" "github.com/urfave/cli" + + "github.com/photoprism/photoprism/pkg/fs" + "github.com/photoprism/photoprism/pkg/report" ) var ShowFormatsCommand = cli.Command{ - Name: "formats", - Usage: "Displays supported media and sidecar file formats", + Name: "formats", + Usage: "Displays supported media and sidecar file formats", + Flags: []cli.Flag{ + cli.BoolFlag{ + Name: "compact, c", + Usage: "hide format descriptions to make the output more compact", + }, + cli.BoolFlag{ + Name: "no-wrap, n", + Usage: "disable text-wrapping so the output can be pasted into Markdown files", + }, + }, Action: showFormatsAction, } // showFormatsAction lists supported media and sidecar file formats. func showFormatsAction(ctx *cli.Context) error { - formats := fs.Extensions.Formats(true).Markdown() - fmt.Println(formats) + rows, cols := fs.Extensions.Formats(true).Table(!ctx.Bool("compact"), true, true) + + fmt.Println(report.Markdown(rows, cols, !ctx.Bool("no-wrap"))) + return nil } diff --git a/internal/config/client.go b/internal/config/client.go index 4ed1a5a08..b717a6d49 100644 --- a/internal/config/client.go +++ b/internal/config/client.go @@ -37,6 +37,7 @@ type ClientConfig struct { AppMode string `json:"appMode"` AppIcon string `json:"appIcon"` Debug bool `json:"debug"` + Trace bool `json:"trace"` Test bool `json:"test"` Demo bool `json:"demo"` Sponsor bool `json:"sponsor"` @@ -227,6 +228,7 @@ func (c *Config) PublicConfig() ClientConfig { Version: c.Version(), Copyright: c.Copyright(), Debug: c.Debug(), + Trace: c.Trace(), Test: c.Test(), Demo: c.Demo(), Sponsor: c.Sponsor(), @@ -299,6 +301,7 @@ func (c *Config) GuestConfig() ClientConfig { Version: c.Version(), Copyright: c.Copyright(), Debug: c.Debug(), + Trace: c.Trace(), Test: c.Test(), Demo: c.Demo(), Sponsor: c.Sponsor(), @@ -365,6 +368,7 @@ func (c *Config) UserConfig() ClientConfig { Version: c.Version(), Copyright: c.Copyright(), Debug: c.Debug(), + Trace: c.Trace(), Test: c.Test(), Demo: c.Demo(), Sponsor: c.Sponsor(), diff --git a/internal/config/config.go b/internal/config/config.go index b6e54d99c..c1ee748f5 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -445,11 +445,19 @@ func (c *Config) ImprintUrl() string { return c.options.ImprintUrl } -// Debug checks if debug mode is enabled. +// Debug checks if debug mode is enabled, shows non-essential log messages. func (c *Config) Debug() bool { + if c.Trace() { + return true + } return c.options.Debug } +// Trace checks if trace mode is enabled, shows all log messages. +func (c *Config) Trace() bool { + return c.options.Trace +} + // Test checks if test mode is enabled. func (c *Config) Test() bool { return c.options.Test @@ -467,7 +475,9 @@ func (c *Config) Sponsor() bool { // Public checks if app runs in public mode and requires no authentication. func (c *Config) Public() bool { - if c.Demo() { + if c.Auth() { + return false + } else if c.Demo() { return true } @@ -506,12 +516,19 @@ func (c *Config) AdminPassword() string { return c.options.AdminPassword } +// Auth checks if authentication is always required. +func (c *Config) Auth() bool { + return c.options.Auth +} + // LogLevel returns the Logrus log level. func (c *Config) LogLevel() logrus.Level { // Normalize string. c.options.LogLevel = strings.ToLower(strings.TrimSpace(c.options.LogLevel)) - if c.Debug() && c.options.LogLevel != logrus.TraceLevel.String() { + if c.Trace() { + c.options.LogLevel = logrus.TraceLevel.String() + } else if c.Debug() && c.options.LogLevel != logrus.TraceLevel.String() { c.options.LogLevel = logrus.DebugLevel.String() } @@ -522,6 +539,11 @@ func (c *Config) LogLevel() logrus.Level { } } +// SetLogLevel sets the Logrus log level. +func (c *Config) SetLogLevel(level logrus.Level) { + log.SetLevel(level) +} + // Shutdown services and workers. func (c *Config) Shutdown() { mutex.People.Cancel() diff --git a/internal/config/config_options.go b/internal/config/config_options.go index 8e5f8dd00..04799a50c 100644 --- a/internal/config/config_options.go +++ b/internal/config/config_options.go @@ -22,14 +22,16 @@ type Options struct { Version string `json:"-"` Copyright string `json:"-"` PartnerID string `yaml:"-" json:"-" flag:"partner-id"` - AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` LogLevel string `yaml:"LogLevel" json:"-" flag:"log-level"` Debug bool `yaml:"Debug" json:"Debug" flag:"debug"` + Trace bool `yaml:"Trace" json:"Trace" flag:"Trace"` + AdminPassword string `yaml:"AdminPassword" json:"-" flag:"admin-password"` + Auth bool `yaml:"Auth" json:"-" flag:"auth"` + Public bool `yaml:"Public" json:"-" flag:"public"` Test bool `yaml:"-" json:"Test,omitempty" flag:"test"` Unsafe bool `yaml:"-" json:"-" flag:"unsafe"` Demo bool `yaml:"Demo" json:"-" flag:"demo"` Sponsor bool `yaml:"-" json:"-" flag:"sponsor"` - Public bool `yaml:"Public" json:"-" flag:"public"` ReadOnly bool `yaml:"ReadOnly" json:"ReadOnly" flag:"read-only"` Experimental bool `yaml:"Experimental" json:"Experimental" flag:"experimental"` ConfigPath string `yaml:"ConfigPath" json:"-" flag:"config-path"` diff --git a/internal/config/table.go b/internal/config/table.go new file mode 100644 index 000000000..d798787fb --- /dev/null +++ b/internal/config/table.go @@ -0,0 +1,186 @@ +package config + +import ( + "bytes" + "fmt" + "strings" + "time" + "unicode/utf8" + + "github.com/olekukonko/tablewriter" +) + +// Table returns global config values as a table for reporting. +func (c *Config) Table() (rows [][]string, cols []string) { + cols = []string{"Value", "Name"} + + rows = [][]string{ + {"log-level", c.LogLevel().String()}, + {"debug", fmt.Sprintf("%t", c.Debug())}, + {"trace", fmt.Sprintf("%t", c.Trace())}, + {"admin-password", strings.Repeat("*", utf8.RuneCountInString(c.AdminPassword()))}, + {"auth", fmt.Sprintf("%t", c.Auth())}, + {"public", fmt.Sprintf("%t", c.Public())}, + {"read-only", fmt.Sprintf("%t", c.ReadOnly())}, + {"experimental", fmt.Sprintf("%t", c.Experimental())}, + + // Config. + {"config-file", c.ConfigFile()}, + {"config-path", c.ConfigPath()}, + {"settings-file", c.SettingsFile()}, + + // Originals. + {"originals-path", c.OriginalsPath()}, + {"originals-limit", fmt.Sprintf("%d", c.OriginalsLimit())}, + {"resolution-limit", fmt.Sprintf("%d", c.ResolutionLimit())}, + + // Other paths. + {"storage-path", c.StoragePath()}, + {"sidecar-path", c.SidecarPath()}, + {"cache-path", c.CachePath()}, + {"albums-path", c.AlbumsPath()}, + {"backup-path", c.BackupPath()}, + {"import-path", c.ImportPath()}, + {"assets-path", c.AssetsPath()}, + {"static-path", c.StaticPath()}, + {"build-path", c.BuildPath()}, + {"img-path", c.ImgPath()}, + {"templates-path", c.TemplatesPath()}, + {"temp-path", c.TempPath()}, + + // Workers. + {"workers", fmt.Sprintf("%d", c.Workers())}, + {"wakeup-interval", c.WakeupInterval().String()}, + {"auto-index", fmt.Sprintf("%d", c.AutoIndex()/time.Second)}, + {"auto-import", fmt.Sprintf("%d", c.AutoImport()/time.Second)}, + + // Feature Flags. + {"disable-backups", fmt.Sprintf("%t", c.DisableBackups())}, + {"disable-settings", fmt.Sprintf("%t", c.DisableSettings())}, + {"disable-places", fmt.Sprintf("%t", c.DisablePlaces())}, + {"disable-tensorflow", fmt.Sprintf("%t", c.DisableTensorFlow())}, + {"disable-faces", fmt.Sprintf("%t", c.DisableFaces())}, + {"disable-classification", fmt.Sprintf("%t", c.DisableClassification())}, + {"disable-ffmpeg", fmt.Sprintf("%t", c.DisableFFmpeg())}, + {"disable-exiftool", fmt.Sprintf("%t", c.DisableExifTool())}, + {"disable-heifconvert", fmt.Sprintf("%t", c.DisableHeifConvert())}, + {"disable-darktable", fmt.Sprintf("%t", c.DisableDarktable())}, + {"disable-rawtherapee", fmt.Sprintf("%t", c.DisableRawtherapee())}, + {"disable-sips", fmt.Sprintf("%t", c.DisableSips())}, + {"disable-raw", fmt.Sprintf("%t", c.DisableRaw())}, + + // Format Flags. + {"raw-presets", fmt.Sprintf("%t", c.RawPresets())}, + {"exif-bruteforce", fmt.Sprintf("%t", c.ExifBruteForce())}, + + // TensorFlow. + {"detect-nsfw", fmt.Sprintf("%t", c.DetectNSFW())}, + {"upload-nsfw", fmt.Sprintf("%t", c.UploadNSFW())}, + {"tensorflow-version", c.TensorFlowVersion()}, + {"tensorflow-model-path", c.TensorFlowModelPath()}, + + // UI Defaults. + {"default-locale", c.DefaultLocale()}, + + // Progressive Web App. + {"app-icon", c.AppIcon()}, + {"app-name", c.AppName()}, + {"app-mode", c.AppMode()}, + + // Site Infos. + {"cdn-url", c.CdnUrl("/")}, + {"site-url", c.SiteUrl()}, + {"site-author", c.SiteAuthor()}, + {"site-title", c.SiteTitle()}, + {"site-caption", c.SiteCaption()}, + {"site-description", c.SiteDescription()}, + {"site-preview", c.SitePreview()}, + + // Legal info. + {"imprint", c.Imprint()}, + {"imprint-url", c.ImprintUrl()}, + + // URIs. + {"content-uri", c.ContentUri()}, + {"static-uri", c.StaticUri()}, + {"api-uri", c.ApiUri()}, + {"base-uri", c.BaseUri("/")}, + + // Web Server. + {"http-host", c.HttpHost()}, + {"http-port", fmt.Sprintf("%d", c.HttpPort())}, + {"http-mode", c.HttpMode()}, + + // Database. + {"database-driver", c.DatabaseDriver()}, + {"database-server", c.DatabaseServer()}, + {"database-host", c.DatabaseHost()}, + {"database-port", c.DatabasePortString()}, + {"database-name", c.DatabaseName()}, + {"database-user", c.DatabaseUser()}, + {"database-password", strings.Repeat("*", utf8.RuneCountInString(c.DatabasePassword()))}, + {"database-conns", fmt.Sprintf("%d", c.DatabaseConns())}, + {"database-conns-idle", fmt.Sprintf("%d", c.DatabaseConnsIdle())}, + + // External Tools. + {"darktable-bin", c.DarktableBin()}, + {"darktable-cache-path", c.DarktableCachePath()}, + {"darktable-config-path", c.DarktableConfigPath()}, + {"darktable-blacklist", c.DarktableBlacklist()}, + {"rawtherapee-bin", c.RawtherapeeBin()}, + {"rawtherapee-blacklist", c.RawtherapeeBlacklist()}, + {"sips-bin", c.SipsBin()}, + {"heifconvert-bin", c.HeifConvertBin()}, + {"ffmpeg-bin", c.FFmpegBin()}, + {"ffmpeg-encoder", c.FFmpegEncoder().String()}, + {"ffmpeg-bitrate", fmt.Sprintf("%d", c.FFmpegBitrate())}, + {"exiftool-bin", c.ExifToolBin()}, + + // Thumbnails. + {"download-token", c.DownloadToken()}, + {"preview-token", c.PreviewToken()}, + {"thumb-color", c.ThumbColor()}, + {"thumb-filter", string(c.ThumbFilter())}, + {"thumb-size", fmt.Sprintf("%d", c.ThumbSizePrecached())}, + {"thumb-size-uncached", fmt.Sprintf("%d", c.ThumbSizeUncached())}, + {"thumb-uncached", fmt.Sprintf("%t", c.ThumbUncached())}, + {"thumb-path", c.ThumbPath()}, + {"jpeg-quality", fmt.Sprintf("%d", c.JpegQuality())}, + {"jpeg-size", fmt.Sprintf("%d", c.JpegSize())}, + + // Facial Recognition. + {"face-size", fmt.Sprintf("%d", c.FaceSize())}, + {"face-score", fmt.Sprintf("%f", c.FaceScore())}, + {"face-overlap", fmt.Sprintf("%d", c.FaceOverlap())}, + {"face-cluster-size", fmt.Sprintf("%d", c.FaceClusterSize())}, + {"face-cluster-score", fmt.Sprintf("%d", c.FaceClusterScore())}, + {"face-cluster-core", fmt.Sprintf("%d", c.FaceClusterCore())}, + {"face-cluster-dist", fmt.Sprintf("%f", c.FaceClusterDist())}, + {"face-match-dist", fmt.Sprintf("%f", c.FaceMatchDist())}, + + // Daemon Mode. + {"pid-filename", c.PIDFilename()}, + {"log-filename", c.LogFilename()}, + } + + return rows, cols +} + +// MarkdownTable returns global config values as a markdown formatted table. +func (c *Config) MarkdownTable(autoWrap bool) string { + buf := &bytes.Buffer{} + + rows, cols := c.Table() + + table := tablewriter.NewWriter(buf) + + table.SetAutoWrapText(autoWrap) + table.SetAutoFormatHeaders(false) + table.SetHeader(cols) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + table.AppendBulk(rows) + table.Render() + + return buf.String() +} diff --git a/internal/config/test.go b/internal/config/test.go index 4a17e4a35..94e3e53bf 100644 --- a/internal/config/test.go +++ b/internal/config/test.go @@ -78,9 +78,11 @@ func NewTestOptions(pkg string) *Options { Name: "PhotoPrism", Version: "0.0.0", Copyright: "(c) 2018-2022 Michael Mayer", + Public: true, + Auth: false, Test: true, Debug: true, - Public: true, + Trace: false, Experimental: true, ReadOnly: false, DetectNSFW: true, diff --git a/internal/event/event.go b/internal/event/event.go new file mode 100644 index 000000000..91b6f8153 --- /dev/null +++ b/internal/event/event.go @@ -0,0 +1,27 @@ +/* + +Package event provides a publish-subscribe event hub and a global logger. + +Copyright (c) 2018 - 2022 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an e-mail to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ +package event diff --git a/pkg/fs/extensions.go b/pkg/fs/extensions.go index 4353e25bb..311423b14 100644 --- a/pkg/fs/extensions.go +++ b/pkg/fs/extensions.go @@ -72,8 +72,10 @@ var Extensions = FileExtensions{ ".avi": FormatAvi, ".av1": FormatAV1, ".avc": FormatAVC, - ".mpg": FormatMpg, - ".mpeg": FormatMpg, + ".mpg": FormatMPEG, + ".mpeg": FormatMPEG, + ".mjpg": FormatMJPEG, + ".mjpeg": FormatMJPEG, ".mp2": FormatMp2, ".mpv": FormatMp2, ".mp": FormatMp4, diff --git a/pkg/fs/file_formats.go b/pkg/fs/file_formats.go index b4b6add40..c3eae515b 100644 --- a/pkg/fs/file_formats.go +++ b/pkg/fs/file_formats.go @@ -1,7 +1,6 @@ package fs import ( - "fmt" _ "image/gif" _ "image/jpeg" _ "image/png" @@ -35,6 +34,8 @@ const ( FormatHEVC Format = "hevc" // H.265, High Efficiency Video Coding (HEVC) FormatAVC Format = "avc" // H.264, Advanced Video Coding (AVC), MPEG-4 Part 10, used internally FormatAV1 Format = "av1" // Alliance for Open Media Video + FormatMPEG Format = "mpg" // Moving Picture Experts Group (MPEG) + FormatMJPEG Format = "mjpg" // Motion JPEG (M-JPEG) FormatMov Format = "mov" // QuickTime File Format, can contain AVC, HEVC,... FormatMp2 Format = "mp2" // MPEG-2, H.222/H.262 FormatMp4 Format = "mp4" // MPEG-4 Container based on QuickTime, can contain AVC, HEVC,... @@ -43,7 +44,6 @@ const ( Format3g2 Format = "3g2" // Similar to 3GP, consumes less space & bandwidth FormatFlv Format = "flv" // Flash Video FormatMkv Format = "mkv" // Matroska Multimedia Container, free and open - FormatMpg Format = "mpg" // Moving Picture Experts Group (MPEG) FormatMts Format = "mts" // AVCHD (Advanced Video Coding High Definition) FormatOgv Format = "ogv" // Ogg container format maintained by the Xiph.Org, free and open FormatWMV Format = "wmv" // Windows Media Video @@ -60,55 +60,70 @@ const ( // FormatDesc contains human-readable descriptions for supported file formats var FormatDesc = map[Format]string{ - FormatJpeg: "JPEG (Joint Photographic Experts Group)", + FormatJpeg: "Joint Photographic Experts Group (JPEG)", FormatPng: "Portable Network Graphics", FormatGif: "Graphics Interchange Format", FormatTiff: "Tag Image File Format", FormatBitmap: "Bitmap", - FormatRaw: "Unprocessed Image Data", - FormatMpo: "Stereoscopic 3D Format based on JPEG", - FormatHEIF: "High Efficiency Image File Format", + FormatRaw: "Unprocessed Sensor Data", + FormatMpo: "Stereoscopic (3D JPEG)", + FormatHEIF: "High Efficiency Image File Format (HEIF)", FormatWebP: "Google WebP", FormatWebM: "Google WebM", - FormatHEVC: "H.265, High Efficiency Video Coding", - FormatAVC: "H.264, Advanced Video Coding, MPEG-4 Part 10", - FormatAV1: "Alliance for Open Media", - FormatMov: "QuickTime File Format", - FormatMp2: "MPEG-2, H.222, H.262", - FormatMp4: "MPEG-4 Part 14 Multimedia Container", + FormatHEVC: "High Efficiency Video Coding (HEVC, HVC1, H.265)", + FormatAVC: "Advanced Video Coding (AVC, AVC1, H.264, MPEG-4 Part 10)", + FormatAV1: "AOMedia Video 1 (AV1, AV01)", + FormatMov: "Apple QuickTime (QT)", + FormatMp2: "MPEG 2 (H.262, H.222)", + FormatMp4: "Multimedia Container (MPEG-4 Part 14)", FormatAvi: "Microsoft Audio Video Interleave", - Format3gp: "MPEG-4 Part 12, Mobile Multimedia Container", - Format3g2: "Multimedia Container for 3G CDMA2000, based on 3GP", + FormatWMV: "Microsoft Windows Media", + Format3gp: "Mobile Multimedia Container (MPEG-4 Part 12)", + Format3g2: "Mobile Multimedia Container for CDMA2000 (based on 3GP)", FormatFlv: "Flash Video", - FormatMkv: "Matroska Multimedia Container", - FormatMpg: "MPEG (Moving Picture Experts Group)", - FormatMts: "AVCHD (Advanced Video Coding High Definition)", + FormatMkv: "Matroska Multimedia Container (MKV, MCF, EBML)", + FormatMPEG: "Moving Picture Experts Group (MPEG)", + FormatMJPEG: "Motion JPEG (M-JPEG)", + FormatMts: "Advanced Video Coding High Definition (AVCHD)", FormatOgv: "Ogg Media by Xiph.Org", - FormatWMV: "Windows Media", FormatXMP: "Adobe Extensible Metadata Platform", FormatAAE: "Apple Image Edits", FormatXML: "Extensible Markup Language", FormatJson: "Serialized JSON Data (Exiftool, Google Photos)", - FormatYaml: "Serialized YAML Data (Metadata, Config Values)", + FormatYaml: "Serialized YAML Data (Config, Metadata)", FormatToml: "Serialized TOML Data (Tom's Obvious, Minimal Language)", FormatText: "Plain Text", FormatMarkdown: "Markdown Formatted Text", FormatOther: "Other", } -// Markdown returns a file format table in markdown text format. -func (m FileFormats) Markdown() string { +// Table returns a file format documentation table. +func (m FileFormats) Table(withDesc, withType, withExt bool) (rows [][]string, cols []string) { + cols = make([]string, 0, 4) + cols = append(cols, "Format") - results := make([][]string, 0, len(m)) + t := 0 - max := func(x, y int) int { - if x < y { - return y + if withDesc { + cols = append(cols, "Description") + } + + if withType { + if withDesc { + t = 2 + } else { + t = 1 } - return x + cols = append(cols, "Type") } + if withExt { + cols = append(cols, "Extensions") + } + + rows = make([][]string, 0, len(m)) + ucFirst := func(str string) string { for i, v := range str { return string(unicode.ToUpper(v)) + str[i+1:] @@ -116,40 +131,36 @@ func (m FileFormats) Markdown() string { return "" } - l0, l1, l2, l3 := 12, 12, 12, 12 - for f, ext := range m { sort.Slice(ext, func(i, j int) bool { return ext[i] < ext[j] }) - v := make([]string, 4) - v[0] = strings.ToUpper(f.String()) - v[1] = FormatDesc[f] - v[2] = ucFirst(string(MediaTypes[f])) - v[3] = strings.Join(ext, ", ") - l0, l1, l2, l3 = max(l0, len(v[0])), max(l1, len(v[1])), max(l2, len(v[2])), max(l3, len(v[3])) - results = append(results, v) + v := make([]string, 0, 4) + v = append(v, strings.ToUpper(f.String())) + + if withDesc { + v = append(v, FormatDesc[f]) + } + + if withType { + v = append(v, ucFirst(string(MediaTypes[f]))) + } + + if withExt { + v = append(v, strings.Join(ext, ", ")) + } + + rows = append(rows, v) } - sort.Slice(results, func(i, j int) bool { - if results[i][2] == results[j][2] { - return results[i][0] < results[j][0] + sort.Slice(rows, func(i, j int) bool { + if t > 0 && rows[i][t] == rows[j][t] { + return rows[i][0] < rows[j][0] } else { - return results[i][2] < results[j][2] + return rows[i][t] < rows[j][t] } }) - rows := make([]string, len(results)+2) - - cols := fmt.Sprintf("| %%-%ds | %%-%ds | %%-%ds | %%-%ds |\n", l0, l1, l2, l3) - - rows = append(rows, fmt.Sprintf(cols, "Format", "Description", "Type", "Extensions")) - rows = append(rows, fmt.Sprintf("|:%s-|:%s-|:%s-|:%s-|\n", strings.Repeat("-", l0), strings.Repeat("-", l1), strings.Repeat("-", l2), strings.Repeat("-", l3))) - - for _, r := range results { - rows = append(rows, fmt.Sprintf(cols, r[0], r[1], r[2], r[3])) - } - - return strings.Join(rows, "") + return rows, cols } diff --git a/pkg/fs/file_formats_test.go b/pkg/fs/file_formats_test.go index e1fe9ce58..cb4746c97 100644 --- a/pkg/fs/file_formats_test.go +++ b/pkg/fs/file_formats_test.go @@ -7,11 +7,20 @@ import ( ) func TestFileFormats_Markdown(t *testing.T) { - t.Run("Render", func(t *testing.T) { + t.Run("All", func(t *testing.T) { f := Extensions.Formats(true) - result := f.Markdown() - - // fmt.Print(result) - assert.NotEmpty(t, result) + rows, cols := f.Table(true, true, true) + assert.NotEmpty(t, rows) + assert.NotEmpty(t, cols) + assert.Len(t, cols, 4) + assert.GreaterOrEqual(t, len(rows), 30) + }) + t.Run("Compact", func(t *testing.T) { + f := Extensions.Formats(true) + rows, cols := f.Table(false, false, false) + assert.NotEmpty(t, rows) + assert.NotEmpty(t, cols) + assert.Len(t, cols, 1) + assert.GreaterOrEqual(t, len(rows), 30) }) } diff --git a/pkg/fs/file_mediatype.go b/pkg/fs/file_mediatype.go index 208df8bea..52dbd2e98 100644 --- a/pkg/fs/file_mediatype.go +++ b/pkg/fs/file_mediatype.go @@ -28,7 +28,8 @@ var MediaTypes = map[Format]MediaType{ FormatAvi: MediaVideo, FormatAVC: MediaVideo, FormatAV1: MediaVideo, - FormatMpg: MediaVideo, + FormatMPEG: MediaVideo, + FormatMJPEG: MediaVideo, FormatMp2: MediaVideo, FormatMp4: MediaVideo, FormatMkv: MediaVideo, diff --git a/pkg/report/markdown.go b/pkg/report/markdown.go new file mode 100644 index 000000000..e63068a78 --- /dev/null +++ b/pkg/report/markdown.go @@ -0,0 +1,24 @@ +package report + +import ( + "bytes" + + "github.com/olekukonko/tablewriter" +) + +// Markdown returns markdown formatted table. +func Markdown(rows [][]string, cols []string, autoWrap bool) string { + buf := &bytes.Buffer{} + + table := tablewriter.NewWriter(buf) + + table.SetAutoWrapText(autoWrap) + table.SetAutoFormatHeaders(false) + table.SetHeader(cols) + table.SetBorders(tablewriter.Border{Left: true, Top: false, Right: true, Bottom: false}) + table.SetCenterSeparator("|") + table.AppendBulk(rows) + table.Render() + + return buf.String() +} diff --git a/pkg/report/report.go b/pkg/report/report.go new file mode 100644 index 000000000..9a6237866 --- /dev/null +++ b/pkg/report/report.go @@ -0,0 +1,27 @@ +/* + +Package report provides rendering of report results, for example as Markdown. + +Copyright (c) 2018 - 2022 Michael Mayer + + This program is free software: you can redistribute it and/or modify + it under Version 3 of the GNU Affero General Public License (the "AGPL"): + + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + The AGPL is supplemented by our Trademark and Brand Guidelines, + which describe how our Brand Assets may be used: + + +Feel free to send an e-mail to hello@photoprism.app if you have questions, +want to support our work, or just want to say hello. + +Additional information can be found in our Developer Guide: + + +*/ +package report