- New config parameter "resume_jobs" which can disable auto-resuming jobs at timeline open. (closes #159) - Renamed "a" to "app" in one method using "Rename symbol" (not "Change all occurrences"), which surprisingly updated the identifier in ALL methods. That must be new. Anyway, that's the huge diff. - Minor fix to metadata merge that does a more proper nil check to avoid a panic. - Changed some omitempty to omitzero
387 lines
11 KiB
Go
387 lines
11 KiB
Go
/*
|
|
Timelinize
|
|
Copyright (c) 2013 Matthew Holt
|
|
|
|
This program is free software: you can redistribute it and/or modify
|
|
it under the terms of the GNU Affero General Public License as published
|
|
by the Free Software Foundation, either version 3 of the License, or
|
|
(at your option) any later version.
|
|
|
|
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.
|
|
|
|
You should have received a copy of the GNU Affero General Public License
|
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
package tlzapp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"reflect"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/timelinize/timelinize/timeline"
|
|
)
|
|
|
|
func (app *App) registerCommands() {
|
|
// TODO: register flags with flag package... and command help... these will probably need to know the payload structure...
|
|
// TODO: make endpoint URIs consistent with App methods and frontend function names
|
|
app.commands = map[string]Endpoint{
|
|
"add-entity": {
|
|
Handler: app.server.handleAddEntity,
|
|
Method: http.MethodPost,
|
|
Payload: addEntityPayload{},
|
|
Help: "Creates a new entity.",
|
|
},
|
|
"build-info": {
|
|
Handler: app.server.handleBuildInfo,
|
|
Method: http.MethodGet,
|
|
Help: "Displays information about this build.",
|
|
},
|
|
"cancel-jobs": {
|
|
Handler: app.server.handleCancelJobs,
|
|
Method: http.MethodPost,
|
|
Payload: jobsPayload{},
|
|
Help: "Cancels active jobs.",
|
|
},
|
|
"change-settings": {
|
|
Handler: app.server.handleChangeSettings,
|
|
Method: http.MethodPost,
|
|
Payload: changeSettingsPayload{},
|
|
Help: "Changes settings.",
|
|
},
|
|
"close-repository": {
|
|
Handler: app.server.handleCloseRepo,
|
|
Method: http.MethodPost,
|
|
Payload: "",
|
|
Help: "Close a timeline repository.",
|
|
},
|
|
"conversation": {
|
|
Handler: app.server.handleConversation,
|
|
Method: http.MethodPost,
|
|
Payload: timeline.ItemSearchParams{},
|
|
Help: "Loads a conversation.",
|
|
},
|
|
"data-sources": {
|
|
Handler: app.server.handleGetDataSources,
|
|
Method: http.MethodGet,
|
|
Help: "Returns the supported data sources.",
|
|
},
|
|
"delete-items": {
|
|
Handler: app.server.handleDeleteItems,
|
|
Method: http.MethodDelete,
|
|
Payload: deleteItemsPayload{},
|
|
Help: "Deletes items from a timeline.",
|
|
},
|
|
"file-stat": {
|
|
Handler: app.server.handleFileStat,
|
|
Method: http.MethodPost,
|
|
Payload: "",
|
|
Help: "Returns basic information about a file or directory.",
|
|
},
|
|
"file-listing": {
|
|
Handler: app.server.handleFileListing,
|
|
Method: http.MethodPost,
|
|
Payload: payloadFileListing{},
|
|
Help: "Returns the list of files for a given path.",
|
|
},
|
|
"file-selector-roots": {
|
|
Handler: app.server.handleFileSelectorRoots,
|
|
Method: http.MethodGet,
|
|
Help: "Returns a list of root paths for a file picker.",
|
|
},
|
|
"get-entity": {
|
|
Handler: app.server.handleGetEntity,
|
|
Method: http.MethodPost,
|
|
Payload: getEntityPayload{},
|
|
Help: "Returns information about the given entity.",
|
|
},
|
|
"import": {
|
|
Handler: app.server.handleImport,
|
|
Method: http.MethodPost,
|
|
Payload: ImportParameters{},
|
|
Help: "Starts an import job.",
|
|
},
|
|
"item-classifications": {
|
|
Handler: app.server.handleItemClassifications,
|
|
Method: http.MethodPost,
|
|
Payload: "",
|
|
Help: "Returns the item classifications for the given timeline.",
|
|
},
|
|
"jobs": {
|
|
Handler: app.server.handleJobs,
|
|
Method: methodQuery,
|
|
Payload: jobsPayload{},
|
|
ContentType: JSON,
|
|
Help: "Gets current information about jobs.",
|
|
},
|
|
"logs": {
|
|
Handler: app.server.handleLogs,
|
|
Method: http.MethodGet,
|
|
Help: "Initiates a WebSocket connection to send logs.",
|
|
},
|
|
"merge-entities": {
|
|
Handler: app.server.handleMergeEntities,
|
|
Method: http.MethodPost,
|
|
Payload: mergeEntitiesPayload{},
|
|
Help: "Merge two entities together.",
|
|
},
|
|
"next-graph": {
|
|
Handler: app.server.handleNextGraph,
|
|
Method: http.MethodGet,
|
|
Help: "Gets the next graph from an interactive import.",
|
|
},
|
|
"open-repositories": {
|
|
Handler: app.server.handleRepos,
|
|
Method: http.MethodGet,
|
|
Help: "Returns the list of open timelines.",
|
|
},
|
|
"open-repository": {
|
|
Handler: app.server.handleOpenRepo,
|
|
Method: http.MethodPost,
|
|
Payload: openRepoPayload{},
|
|
Help: "Open a timeline repository.",
|
|
},
|
|
"pause-job": {
|
|
Handler: app.server.handlePauseJob,
|
|
Method: http.MethodPost,
|
|
Payload: jobPayload{},
|
|
Help: "Pauses an active job.",
|
|
},
|
|
"plan-import": {
|
|
Handler: app.server.handlePlanImport,
|
|
Method: http.MethodPost,
|
|
Payload: PlannerOptions{},
|
|
Help: "Proposes an import plan in preparation for performing a data import.",
|
|
},
|
|
"recent-conversations": {
|
|
Handler: app.server.handleRecentConversations,
|
|
Method: http.MethodPost,
|
|
Payload: timeline.ItemSearchParams{},
|
|
Help: "Loads recent conversations.",
|
|
},
|
|
"repository-empty": {
|
|
Handler: app.server.handleRepositoryEmpty,
|
|
Method: http.MethodPost,
|
|
Payload: "",
|
|
Help: "Returns whether the repository is empty or not.",
|
|
},
|
|
"settings": {
|
|
Handler: app.server.handleSettings,
|
|
Method: http.MethodGet,
|
|
Help: "Returns settings for the application and opened timelines.",
|
|
},
|
|
"submit-graph": {
|
|
Handler: app.server.handleSubmitGraph,
|
|
Method: http.MethodPost,
|
|
Payload: submitGraphPayload{},
|
|
Help: "Submits a graph for processing during an interactive import.",
|
|
},
|
|
"search-entities": {
|
|
Handler: app.server.handleSearchEntities,
|
|
Method: http.MethodPost,
|
|
Payload: timeline.EntitySearchParams{},
|
|
Help: "Finds and filters entities in a timeline.",
|
|
},
|
|
"search-items": {
|
|
Handler: app.server.handleSearchItems,
|
|
Method: http.MethodPost,
|
|
Payload: timeline.ItemSearchParams{},
|
|
Help: "Finds and filters items in a timeline.",
|
|
},
|
|
"start-job": {
|
|
Handler: app.server.handleStartJob,
|
|
Method: http.MethodPost,
|
|
Payload: jobPayload{},
|
|
Help: "Starts a job.",
|
|
},
|
|
"charts": {
|
|
Handler: app.server.handleCharts,
|
|
Method: http.MethodGet,
|
|
Help: "Returns statistics about the timeline for use in charts.",
|
|
},
|
|
"unpause-job": {
|
|
Handler: app.server.handleUnpauseJob,
|
|
Method: http.MethodPost,
|
|
Payload: jobPayload{},
|
|
Help: "Unpauses a paused job.",
|
|
},
|
|
}
|
|
}
|
|
|
|
type Endpoint struct {
|
|
Method string
|
|
ContentType ContentType
|
|
Payload any
|
|
Handler handlerFunc
|
|
Help string
|
|
}
|
|
|
|
// GetContentType returns the Content-Type of the endpoint
|
|
// considering its default of JSON if method is POST, PUT, PATCH, or DELETE.
|
|
func (e Endpoint) GetContentType() ContentType {
|
|
if e.ContentType == None && e.Payload != nil &&
|
|
(e.Method == http.MethodPost || e.Method == http.MethodPut ||
|
|
e.Method == http.MethodPatch || e.Method == http.MethodDelete ||
|
|
e.Method == methodQuery) {
|
|
return JSON
|
|
}
|
|
return e.ContentType
|
|
}
|
|
|
|
// GET but officially supports a request body.
|
|
const methodQuery = "QUERY"
|
|
|
|
type ctxKey string
|
|
|
|
var ctxKeyPayload ctxKey = "payload"
|
|
|
|
func (e Endpoint) ServeHTTP(w http.ResponseWriter, r *http.Request) error {
|
|
switch e.GetContentType() {
|
|
case JSON:
|
|
payload := reflect.New(reflect.TypeOf(e.Payload)).Interface()
|
|
if r.ContentLength > 0 {
|
|
err := json.NewDecoder(r.Body).Decode(&payload)
|
|
if err != nil {
|
|
return Error{
|
|
Err: err,
|
|
HTTPStatus: http.StatusBadRequest,
|
|
Log: "decoding request body as JSON",
|
|
Message: "Invalid JSON in request body.",
|
|
}
|
|
}
|
|
}
|
|
r = r.WithContext(context.WithValue(r.Context(), ctxKeyPayload, payload))
|
|
case Form, None:
|
|
}
|
|
|
|
return e.Handler(w, r)
|
|
}
|
|
|
|
func (app *App) CommandLineHelp() string {
|
|
// alphabetize the commands list
|
|
type commandEndpoint struct {
|
|
command string
|
|
endpoint Endpoint
|
|
}
|
|
commands := make([]commandEndpoint, 0, len(app.commands))
|
|
for command, endpoint := range app.commands {
|
|
commands = append(commands, commandEndpoint{command, endpoint})
|
|
}
|
|
sort.Slice(commands, func(i, j int) bool {
|
|
return commands[i].command < commands[j].command
|
|
})
|
|
|
|
var sb strings.Builder
|
|
|
|
sb.WriteString(`Timelinize is an application to curate your portion of the global digital record by organizing
|
|
your own data on your own computer.
|
|
|
|
It consists of a server, command line client, and web client. Timelinize can be used via a web
|
|
page / GUI, command line (CLI), or HTTP JSON API. The CLI and API have symmetric commands
|
|
(inputs and outputs).
|
|
|
|
Usage:
|
|
timelinize [command] [args...]
|
|
|
|
Examples:
|
|
$ timelinize
|
|
$ timelinize serve
|
|
$ timelinize search-items --repo ... --data-text ...
|
|
|
|
Available Commands:`)
|
|
|
|
for _, pair := range commands {
|
|
sb.WriteString("\n ")
|
|
sb.WriteString(pair.command)
|
|
|
|
if pair.endpoint.Payload != nil {
|
|
val := reflect.ValueOf(pair.endpoint.Payload)
|
|
kind := val.Kind()
|
|
|
|
switch kind { //nolint:exhaustive
|
|
case reflect.Slice:
|
|
sb.WriteString(" <")
|
|
sb.WriteString(val.Type().Elem().String())
|
|
sb.WriteString("...>")
|
|
case reflect.Struct:
|
|
fields := nestedFields(pair.endpoint.Payload)
|
|
|
|
for i, field := range fields {
|
|
jsonStructTag := field.Tag.Get("json")
|
|
if jsonStructTag == "" {
|
|
continue
|
|
}
|
|
dataType := field.Type
|
|
argName, omitEmpty, cut := strings.Cut(jsonStructTag, ",")
|
|
if argName == "-" {
|
|
continue
|
|
}
|
|
if argName != "" {
|
|
argName = strings.ReplaceAll(argName, "_", "-")
|
|
}
|
|
if i > 0 && i%3 == 0 {
|
|
sb.WriteString("\n\t\t")
|
|
}
|
|
optional := cut && omitEmpty == "omitempty"
|
|
if optional {
|
|
sb.WriteString(fmt.Sprintf(" [--%s <%s>]", argName, dataType))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf(" --%s <%s>", argName, dataType))
|
|
}
|
|
}
|
|
default:
|
|
sb.WriteString(" <")
|
|
sb.WriteString(kind.String())
|
|
sb.WriteRune('>')
|
|
}
|
|
}
|
|
|
|
sb.WriteString("\n ")
|
|
sb.WriteString(pair.endpoint.Help)
|
|
sb.WriteRune('\n')
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// nestedFields flattens the struct fields from embedded structs of thing,
|
|
// which must be a struct.
|
|
func nestedFields(thing any) []reflect.StructField {
|
|
val := reflect.ValueOf(thing)
|
|
typ := reflect.TypeOf(thing)
|
|
|
|
var fields []reflect.StructField
|
|
|
|
for i := range typ.NumField() {
|
|
typf := typ.Field(i)
|
|
valf := val.Field(i)
|
|
|
|
if valf.Kind() == reflect.Struct && typf.Anonymous {
|
|
fields = append(fields, nestedFields(valf.Interface())...)
|
|
} else {
|
|
fields = append(fields, typf)
|
|
}
|
|
}
|
|
|
|
return fields
|
|
}
|
|
|
|
// ContentType is an HTTP Content-Type value.
|
|
type ContentType string
|
|
|
|
// Content types that are supported.
|
|
const (
|
|
JSON ContentType = "application/json"
|
|
Form ContentType = "application/x-www-form-urlencoded"
|
|
None ContentType = ""
|
|
)
|
|
|
|
const apiBasePath = "/api/"
|