1
0
Fork 0
timelinize/tlzapp/endpoints.go
Matthew Holt e7650c784a
Some minor changes
- 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
2025-10-22 15:13:32 -06:00

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/"