From f47dbefb6780169c6917f1e3883853dea83e7e60 Mon Sep 17 00:00:00 2001 From: derfenix Date: Tue, 4 Apr 2023 21:51:45 +0300 Subject: [PATCH] web ui: index and basic details page, api refactoring --- README.md | 18 +- adapters/processors/pdf.go | 1 + adapters/processors/processors.go | 63 ++++++ adapters/repository/badger/page.go | 64 +++++- api/openapi.yaml | 15 +- api/openapi/oas_json_gen.go | 308 ++++++++++++++++++++++++++++- api/openapi/oas_schemas_gen.go | 106 +++++++++- application/application.go | 10 +- config/config.go | 6 +- entity/page.go | 8 + entity/worker.go | 15 ++ ports/rest/converter.go | 11 ++ ports/rest/service.go | 20 +- ports/rest/ui.go | 15 +- ui/basic/index.html | 47 +++++ ui/basic/lib.js | 2 + ui/basic/main.js | 90 +++++++++ ui/basic/style.css | 61 ++++++ ui/embed.go | 2 +- ui/static/index.html | 14 -- ui/static/style.css | 3 - 21 files changed, 821 insertions(+), 58 deletions(-) create mode 100644 ui/basic/index.html create mode 100644 ui/basic/lib.js create mode 100644 ui/basic/main.js create mode 100644 ui/basic/style.css delete mode 100644 ui/static/index.html delete mode 100644 ui/static/style.css diff --git a/README.md b/README.md index 18fa00b..ce0798b 100644 --- a/README.md +++ b/README.md @@ -24,13 +24,17 @@ variables: * **LOGGING_DEBUG** — enable debug logs (default `false`) * **API** * **API_ADDRESS** — address the API server will listen (default `0.0.0.0:5001`) +* **UI** + * **UI_ENABLED** — Enable builtin web UI (default `true`) + * **UI_PREFIX** — Prefix for the web UI (default `/`) + * **UI_THEME** — UI theme name (default `basic`). No other values available yet * **PDF** * **PDF_LANDSCAPE** — use landscape page orientation instead of portrait (default `false`) * **PDF_GRAYSCALE** — use grayscale filter for the output pdf (default `false`) * **PDF_MEDIA_PRINT** — use media type `print` for the request (default `true`) * **PDF_ZOOM** — zoom page (default `1.0` i.e. no actual zoom) - * **PDF_VIEWPORT** — use specified viewport value (default `1920x1080`) - * **PDF_DPI** — use specified DPI value for the output pdf (default `300`) + * **PDF_VIEWPORT** — use specified viewport value (default `1280x720`) + * **PDF_DPI** — use specified DPI value for the output pdf (default `150`) * **PDF_FILENAME** — use specified name for output pdf file (default `page.pdf`) @@ -60,7 +64,7 @@ docker compose up -d webarchive ### 2. Add a page ```shell -curl -X POST --location "http://localhost:5001/pages" \ +curl -X POST --location "http://localhost:5001/api/v1/pages" \ -H "Content-Type: application/json" \ -d "{ \"url\": \"https://github.com/wkhtmltopdf/wkhtmltopdf/issues/1937\", @@ -75,13 +79,13 @@ or ```shell curl -X POST --location \ - "http://localhost:5001/pages?url=https%3A%2F%2Fgithub.com%2Fwkhtmltopdf%2Fwkhtmltopdf%2Fissues%2F1937&formats=pdf%2Cheaders&description=Foo+Bar" + "http://localhost:5001/api/v1/pages?url=https%3A%2F%2Fgithub.com%2Fwkhtmltopdf%2Fwkhtmltopdf%2Fissues%2F1937&formats=pdf%2Cheaders&description=Foo+Bar" ``` ### 3. Get the page's info ```shell -curl -X GET --location "http://localhost:5001/pages/$page_id" | jq . +curl -X GET --location "http://localhost:5001/api/v1/pages/$page_id" | jq . ``` where `$page_id` — value of the `id` field from previous command response. If `status` field in response is `success` (or `with_errors`) - the `results` field @@ -90,7 +94,7 @@ will contain all processed formats with ids of the stored files. ### 4. Open file in browser ```shell -xdg-open "http://localhost:5001/pages/$page_id/file/$file_id" +xdg-open "http://localhost:5001/api/v1/pages/$page_id/file/$file_id" ``` Where `$page_id` — value of the `id` field from previous command response, and `$file_id` — the id of interesting file. @@ -98,7 +102,7 @@ Where `$page_id` — value of the `id` field from previous command response, an ### 5. List all stored pages ```shell -curl -X GET --location "http://localhost:5001/pages" | jq . +curl -X GET --location "http://localhost:5001/api/v1/pages" | jq . ``` ## Roadmap diff --git a/adapters/processors/pdf.go b/adapters/processors/pdf.go index 235d250..abd171a 100644 --- a/adapters/processors/pdf.go +++ b/adapters/processors/pdf.go @@ -47,6 +47,7 @@ func (p *PDF) Process(_ context.Context, url string) ([]entity.File, error) { page.FooterFontSize.Set(10) page.Zoom.Set(p.cfg.Zoom) page.ViewportSize.Set(p.cfg.Viewport) + page.NoBackground.Set(true) gen.AddPage(page) diff --git a/adapters/processors/processors.go b/adapters/processors/processors.go index 5e2c975..55c225d 100644 --- a/adapters/processors/processors.go +++ b/adapters/processors/processors.go @@ -8,6 +8,8 @@ import ( "net/http/cookiejar" "time" + "golang.org/x/net/html" + "github.com/derfenix/webarchive/config" "github.com/derfenix/webarchive/entity" ) @@ -52,6 +54,7 @@ func NewProcessors(cfg config.Config) (*Processors, error) { } procs := Processors{ + client: httpClient, processors: map[entity.Format]processor{ entity.FormatHeaders: NewHeaders(httpClient), entity.FormatPDF: NewPDF(cfg.PDF), @@ -64,6 +67,7 @@ func NewProcessors(cfg config.Config) (*Processors, error) { type Processors struct { processors map[entity.Format]processor + client *http.Client } func (p *Processors) Process(ctx context.Context, format entity.Format, url string) entity.Result { @@ -93,3 +97,62 @@ func (p *Processors) OverrideProcessor(format entity.Format, proc processor) err return nil } + +func (p *Processors) GetMeta(ctx context.Context, url string) (entity.Meta, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return entity.Meta{}, fmt.Errorf("new request: %w", err) + } + + response, err := p.client.Do(req) + if err != nil { + return entity.Meta{}, fmt.Errorf("do request: %w", err) + } + + if response.StatusCode != http.StatusOK { + return entity.Meta{}, fmt.Errorf("want status 200, got %d", response.StatusCode) + } + + if response.Body == nil { + return entity.Meta{}, fmt.Errorf("empty response body") + } + + defer func() { + _ = response.Body.Close() + }() + + htmlNode, err := html.Parse(response.Body) + if err != nil { + return entity.Meta{}, fmt.Errorf("parse response body: %w", err) + } + + meta := entity.Meta{} + getMetaData(htmlNode, &meta) + + return meta, nil +} + +func getMetaData(n *html.Node, meta *entity.Meta) { + if n == nil { + return + } + + for c := n.FirstChild; c != nil; c = c.NextSibling { + if c.Type == html.ElementNode && c.Data == "title" { + meta.Title = c.FirstChild.Data + } + if c.Type == html.ElementNode && c.Data == "meta" { + attrs := make(map[string]string) + for _, attr := range c.Attr { + attrs[attr.Key] = attr.Val + } + + name, ok := attrs["name"] + if ok && name == "description" { + meta.Description = attrs["content"] + } + } + + getMetaData(c, meta) + } +} diff --git a/adapters/repository/badger/page.go b/adapters/repository/badger/page.go index 2c2f360..807c877 100644 --- a/adapters/repository/badger/page.go +++ b/adapters/repository/badger/page.go @@ -64,18 +64,18 @@ func (p *Page) GetFile(_ context.Context, pageID, fileID uuid.UUID) (*entity.Fil return file, nil } -func (p *Page) Save(_ context.Context, site *entity.Page) error { +func (p *Page) Save(_ context.Context, page *entity.Page) error { if p.db.IsClosed() { return ErrDBClosed } - marshaled, err := marshal(site) + marshaled, err := marshal(page) if err != nil { return fmt.Errorf("marshal data: %w", err) } if err := p.db.Update(func(txn *badger.Txn) error { - if err := txn.Set(p.key(site), marshaled); err != nil { + if err := txn.Set(p.key(page), marshaled); err != nil { return fmt.Errorf("put data: %w", err) } @@ -151,6 +151,64 @@ func (p *Page) ListAll(ctx context.Context) ([]*entity.Page, error) { Formats: page.Formats, Version: page.Version, Status: page.Status, + Meta: page.Meta, + }) + } + + return nil + }) + + if err != nil { + return nil, fmt.Errorf("view: %w", err) + } + + sort.Slice(pages, func(i, j int) bool { + return pages[i].Created.After(pages[j].Created) + }) + + return pages, nil +} + +func (p *Page) ListUnprocessed(ctx context.Context) ([]*entity.Page, error) { + pages := make([]*entity.Page, 0, 100) + + err := p.db.View(func(txn *badger.Txn) error { + iterator := txn.NewIterator(badger.DefaultIteratorOptions) + + defer iterator.Close() + + for iterator.Seek(p.prefix); iterator.ValidForPrefix(p.prefix); iterator.Next() { + if err := ctx.Err(); err != nil { + return fmt.Errorf("context canceled: %w", err) + } + + var page entity.Page + + err := iterator.Item().Value(func(val []byte) error { + if err := unmarshal(val, &page); err != nil { + return fmt.Errorf("unmarshal: %w", err) + } + + return nil + }) + + if err != nil { + return fmt.Errorf("get item: %w", err) + } + + if page.Status != entity.StatusProcessing { + continue + } + + pages = append(pages, &entity.Page{ + ID: page.ID, + URL: page.URL, + Description: page.Description, + Created: page.Created, + Formats: page.Formats, + Version: page.Version, + Status: page.Status, + Meta: page.Meta, }) } diff --git a/api/openapi.yaml b/api/openapi.yaml index e5719c2..690a821 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -4,7 +4,7 @@ info: description: API description in Markdown. version: 1.0.0 servers: - - url: 'https://api.example.com' + - url: 'https://api.example.com/api/v1' paths: /pages: get: @@ -183,12 +183,25 @@ components: $ref: '#/components/schemas/format' status: $ref: '#/components/schemas/status' + meta: + type: object + properties: + title: + type: string + description: + type: string + error: + type: string + required: + - title + - description required: - id - url - formats - status - created + - meta result: type: object properties: diff --git a/api/openapi/oas_json_gen.go b/api/openapi/oas_json_gen.go index 5f5e1e0..0568e88 100644 --- a/api/openapi/oas_json_gen.go +++ b/api/openapi/oas_json_gen.go @@ -534,14 +534,20 @@ func (s *Page) encodeFields(e *jx.Encoder) { e.FieldStart("status") s.Status.Encode(e) } + { + + e.FieldStart("meta") + s.Meta.Encode(e) + } } -var jsonFieldsNameOfPage = [5]string{ +var jsonFieldsNameOfPage = [6]string{ 0: "id", 1: "url", 2: "created", 3: "formats", 4: "status", + 5: "meta", } // Decode decodes Page from json. @@ -617,6 +623,16 @@ func (s *Page) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"status\"") } + case "meta": + requiredBitSet[0] |= 1 << 5 + if err := func() error { + if err := s.Meta.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"meta\"") + } default: return d.Skip() } @@ -627,7 +643,7 @@ func (s *Page) Decode(d *jx.Decoder) error { // Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{ - 0b00011111, + 0b00111111, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -673,6 +689,138 @@ func (s *Page) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *PageMeta) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PageMeta) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("title") + e.Str(s.Title) + } + { + + e.FieldStart("description") + e.Str(s.Description) + } + { + if s.Error.Set { + e.FieldStart("error") + s.Error.Encode(e) + } + } +} + +var jsonFieldsNameOfPageMeta = [3]string{ + 0: "title", + 1: "description", + 2: "error", +} + +// Decode decodes PageMeta from json. +func (s *PageMeta) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PageMeta to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "title": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Title = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"title\"") + } + case "description": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Description = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"description\"") + } + case "error": + if err := func() error { + s.Error.Reset() + if err := s.Error.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PageMeta") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPageMeta) { + name = jsonFieldsNameOfPageMeta[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PageMeta) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PageMeta) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode implements json.Marshaler. func (s *PageWithResults) Encode(e *jx.Encoder) { e.ObjStart() @@ -711,6 +859,11 @@ func (s *PageWithResults) encodeFields(e *jx.Encoder) { e.FieldStart("status") s.Status.Encode(e) } + { + + e.FieldStart("meta") + s.Meta.Encode(e) + } { e.FieldStart("results") @@ -722,13 +875,14 @@ func (s *PageWithResults) encodeFields(e *jx.Encoder) { } } -var jsonFieldsNameOfPageWithResults = [6]string{ +var jsonFieldsNameOfPageWithResults = [7]string{ 0: "id", 1: "url", 2: "created", 3: "formats", 4: "status", - 5: "results", + 5: "meta", + 6: "results", } // Decode decodes PageWithResults from json. @@ -804,8 +958,18 @@ func (s *PageWithResults) Decode(d *jx.Decoder) error { }(); err != nil { return errors.Wrap(err, "decode field \"status\"") } - case "results": + case "meta": requiredBitSet[0] |= 1 << 5 + if err := func() error { + if err := s.Meta.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"meta\"") + } + case "results": + requiredBitSet[0] |= 1 << 6 if err := func() error { s.Results = make([]Result, 0) if err := d.Arr(func(d *jx.Decoder) error { @@ -832,7 +996,7 @@ func (s *PageWithResults) Decode(d *jx.Decoder) error { // Validate required fields. var failures []validate.FieldError for i, mask := range [1]uint8{ - 0b00111111, + 0b01111111, } { if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { // Mask only required fields and check equality to mask using XOR. @@ -878,6 +1042,138 @@ func (s *PageWithResults) UnmarshalJSON(data []byte) error { return s.Decode(d) } +// Encode implements json.Marshaler. +func (s *PageWithResultsMeta) Encode(e *jx.Encoder) { + e.ObjStart() + s.encodeFields(e) + e.ObjEnd() +} + +// encodeFields encodes fields. +func (s *PageWithResultsMeta) encodeFields(e *jx.Encoder) { + { + + e.FieldStart("title") + e.Str(s.Title) + } + { + + e.FieldStart("description") + e.Str(s.Description) + } + { + if s.Error.Set { + e.FieldStart("error") + s.Error.Encode(e) + } + } +} + +var jsonFieldsNameOfPageWithResultsMeta = [3]string{ + 0: "title", + 1: "description", + 2: "error", +} + +// Decode decodes PageWithResultsMeta from json. +func (s *PageWithResultsMeta) Decode(d *jx.Decoder) error { + if s == nil { + return errors.New("invalid: unable to decode PageWithResultsMeta to nil") + } + var requiredBitSet [1]uint8 + + if err := d.ObjBytes(func(d *jx.Decoder, k []byte) error { + switch string(k) { + case "title": + requiredBitSet[0] |= 1 << 0 + if err := func() error { + v, err := d.Str() + s.Title = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"title\"") + } + case "description": + requiredBitSet[0] |= 1 << 1 + if err := func() error { + v, err := d.Str() + s.Description = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"description\"") + } + case "error": + if err := func() error { + s.Error.Reset() + if err := s.Error.Decode(d); err != nil { + return err + } + return nil + }(); err != nil { + return errors.Wrap(err, "decode field \"error\"") + } + default: + return d.Skip() + } + return nil + }); err != nil { + return errors.Wrap(err, "decode PageWithResultsMeta") + } + // Validate required fields. + var failures []validate.FieldError + for i, mask := range [1]uint8{ + 0b00000011, + } { + if result := (requiredBitSet[i] & mask) ^ mask; result != 0 { + // Mask only required fields and check equality to mask using XOR. + // + // If XOR result is not zero, result is not equal to expected, so some fields are missed. + // Bits of fields which would be set are actually bits of missed fields. + missed := bits.OnesCount8(result) + for bitN := 0; bitN < missed; bitN++ { + bitIdx := bits.TrailingZeros8(result) + fieldIdx := i*8 + bitIdx + var name string + if fieldIdx < len(jsonFieldsNameOfPageWithResultsMeta) { + name = jsonFieldsNameOfPageWithResultsMeta[fieldIdx] + } else { + name = strconv.Itoa(fieldIdx) + } + failures = append(failures, validate.FieldError{ + Name: name, + Error: validate.ErrFieldRequired, + }) + // Reset bit. + result &^= 1 << bitIdx + } + } + } + if len(failures) > 0 { + return &validate.Error{Fields: failures} + } + + return nil +} + +// MarshalJSON implements stdjson.Marshaler. +func (s *PageWithResultsMeta) MarshalJSON() ([]byte, error) { + e := jx.Encoder{} + s.Encode(&e) + return e.Bytes(), nil +} + +// UnmarshalJSON implements stdjson.Unmarshaler. +func (s *PageWithResultsMeta) UnmarshalJSON(data []byte) error { + d := jx.DecodeBytes(data) + return s.Decode(d) +} + // Encode encodes Pages as json. func (s Pages) Encode(e *jx.Encoder) { unwrapped := []Page(s) diff --git a/api/openapi/oas_schemas_gen.go b/api/openapi/oas_schemas_gen.go index dda0d7e..8cc81a8 100644 --- a/api/openapi/oas_schemas_gen.go +++ b/api/openapi/oas_schemas_gen.go @@ -324,6 +324,7 @@ type Page struct { Created time.Time `json:"created"` Formats []Format `json:"formats"` Status Status `json:"status"` + Meta PageMeta `json:"meta"` } // GetID returns the value of ID. @@ -351,6 +352,11 @@ func (s *Page) GetStatus() Status { return s.Status } +// GetMeta returns the value of Meta. +func (s *Page) GetMeta() PageMeta { + return s.Meta +} + // SetID sets the value of ID. func (s *Page) SetID(val uuid.UUID) { s.ID = val @@ -376,17 +382,59 @@ func (s *Page) SetStatus(val Status) { s.Status = val } +// SetMeta sets the value of Meta. +func (s *Page) SetMeta(val PageMeta) { + s.Meta = val +} + func (*Page) addPageRes() {} +type PageMeta struct { + Title string `json:"title"` + Description string `json:"description"` + Error OptString `json:"error"` +} + +// GetTitle returns the value of Title. +func (s *PageMeta) GetTitle() string { + return s.Title +} + +// GetDescription returns the value of Description. +func (s *PageMeta) GetDescription() string { + return s.Description +} + +// GetError returns the value of Error. +func (s *PageMeta) GetError() OptString { + return s.Error +} + +// SetTitle sets the value of Title. +func (s *PageMeta) SetTitle(val string) { + s.Title = val +} + +// SetDescription sets the value of Description. +func (s *PageMeta) SetDescription(val string) { + s.Description = val +} + +// SetError sets the value of Error. +func (s *PageMeta) SetError(val OptString) { + s.Error = val +} + // Merged schema. // Ref: #/components/schemas/pageWithResults type PageWithResults struct { - ID uuid.UUID `json:"id"` - URL string `json:"url"` - Created time.Time `json:"created"` - Formats []Format `json:"formats"` - Status Status `json:"status"` - Results []Result `json:"results"` + ID uuid.UUID `json:"id"` + URL string `json:"url"` + Created time.Time `json:"created"` + Formats []Format `json:"formats"` + Status Status `json:"status"` + Meta PageWithResultsMeta `json:"meta"` + Results []Result `json:"results"` } // GetID returns the value of ID. @@ -414,6 +462,11 @@ func (s *PageWithResults) GetStatus() Status { return s.Status } +// GetMeta returns the value of Meta. +func (s *PageWithResults) GetMeta() PageWithResultsMeta { + return s.Meta +} + // GetResults returns the value of Results. func (s *PageWithResults) GetResults() []Result { return s.Results @@ -444,6 +497,11 @@ func (s *PageWithResults) SetStatus(val Status) { s.Status = val } +// SetMeta sets the value of Meta. +func (s *PageWithResults) SetMeta(val PageWithResultsMeta) { + s.Meta = val +} + // SetResults sets the value of Results. func (s *PageWithResults) SetResults(val []Result) { s.Results = val @@ -451,6 +509,42 @@ func (s *PageWithResults) SetResults(val []Result) { func (*PageWithResults) getPageRes() {} +type PageWithResultsMeta struct { + Title string `json:"title"` + Description string `json:"description"` + Error OptString `json:"error"` +} + +// GetTitle returns the value of Title. +func (s *PageWithResultsMeta) GetTitle() string { + return s.Title +} + +// GetDescription returns the value of Description. +func (s *PageWithResultsMeta) GetDescription() string { + return s.Description +} + +// GetError returns the value of Error. +func (s *PageWithResultsMeta) GetError() OptString { + return s.Error +} + +// SetTitle sets the value of Title. +func (s *PageWithResultsMeta) SetTitle(val string) { + s.Title = val +} + +// SetDescription sets the value of Description. +func (s *PageWithResultsMeta) SetDescription(val string) { + s.Description = val +} + +// SetError sets the value of Error. +func (s *PageWithResultsMeta) SetError(val OptString) { + s.Error = val +} + type Pages []Page // Ref: #/components/schemas/result diff --git a/application/application.go b/application/application.go index 60d791d..1f76550 100644 --- a/application/application.go +++ b/application/application.go @@ -6,6 +6,7 @@ import ( "fmt" "net" "net/http" + "strings" "sync" "time" @@ -48,7 +49,8 @@ func NewApplication(cfg config.Config) (Application, error) { worker := entity.NewWorker(workerCh, pageRepo, processor, log.Named("worker")) server, err := openapi.NewServer( - rest.NewService(pageRepo, workerCh), + rest.NewService(pageRepo, workerCh, processor), + openapi.WithPathPrefix("/api/v1"), openapi.WithMiddleware( func(r middleware.Request, next middleware.Next) (middleware.Response, error) { start := time.Now() @@ -79,13 +81,13 @@ func NewApplication(cfg config.Config) (Application, error) { ui := rest.NewUI(cfg.UI) httpHandler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if ui.IsUIRequest(r) { - ui.ServeHTTP(w, r) + if strings.HasPrefix(r.URL.Path, "/api/") { + server.ServeHTTP(w, r) return } - server.ServeHTTP(w, r) + ui.ServeHTTP(w, r) }) } diff --git a/config/config.go b/config/config.go index f7f3599..a4be3e0 100644 --- a/config/config.go +++ b/config/config.go @@ -37,19 +37,19 @@ type PDF struct { Grayscale bool `env:"GRAYSCALE,default=false"` MediaPrint bool `env:"MEDIA_PRINT,default=true"` Zoom float64 `env:"ZOOM,default=1"` - Viewport string `env:"VIEWPORT,default=1920x1080"` - DPI uint `env:"DPI,default=300"` + Viewport string `env:"VIEWPORT,default=1280x720"` + DPI uint `env:"DPI,default=150"` Filename string `env:"FILENAME,default=page.pdf"` } type API struct { - Prefix string `env:"PREFIX,default=/"` Address string `env:"ADDRESS,default=0.0.0.0:5001"` } type UI struct { Enabled bool `env:"ENABLED,default=true"` Prefix string `env:"PREFIX,default=/"` + Theme string `env:"THEME,default=basic"` } type DB struct { diff --git a/entity/page.go b/entity/page.go index 53edd20..c7287c3 100644 --- a/entity/page.go +++ b/entity/page.go @@ -11,6 +11,7 @@ import ( type Processor interface { Process(ctx context.Context, format Format, url string) Result + GetMeta(ctx context.Context, url string) (Meta, error) } type Format uint8 @@ -37,6 +38,12 @@ const ( StatusWithErrors ) +type Meta struct { + Title string + Description string + Error string +} + func NewPage(url string, description string, formats ...Format) *Page { return &Page{ ID: uuid.New(), @@ -57,6 +64,7 @@ type Page struct { Results Results Version uint16 Status Status + Meta Meta } func (p *Page) SetProcessing() { diff --git a/entity/worker.go b/entity/worker.go index 8630aae..8822f90 100644 --- a/entity/worker.go +++ b/entity/worker.go @@ -9,6 +9,7 @@ import ( type Pages interface { Save(ctx context.Context, page *Page) error + ListUnprocessed(ctx context.Context) ([]*Page, error) } func NewWorker(ch chan *Page, pages Pages, processor Processor, log *zap.Logger) *Worker { @@ -27,6 +28,20 @@ func (w *Worker) Start(ctx context.Context, wg *sync.WaitGroup) { w.log.Info("starting") + wg.Add(1) + go func() { + defer wg.Done() + + unprocessed, err := w.pages.ListUnprocessed(ctx) + if err != nil { + w.log.Error("failed to get unprocessed pages", zap.Error(err)) + } else { + for i := range unprocessed { + w.ch <- unprocessed[i] + } + } + }() + for { select { case <-ctx.Done(): diff --git a/ports/rest/converter.go b/ports/rest/converter.go index efa3ff6..1d36851 100644 --- a/ports/rest/converter.go +++ b/ports/rest/converter.go @@ -2,6 +2,7 @@ package rest import ( "fmt" + "html" "github.com/derfenix/webarchive/api/openapi" "github.com/derfenix/webarchive/entity" @@ -22,6 +23,11 @@ func PageToRestWithResults(page *entity.Page) openapi.PageWithResults { return res }(), Status: StatusToRest(page.Status), + Meta: openapi.PageWithResultsMeta{ + Title: html.EscapeString(page.Meta.Title), + Description: html.EscapeString(page.Meta.Description), + Error: openapi.NewOptString(page.Meta.Error), + }, Results: func() []openapi.Result { results := make([]openapi.Result, len(page.Results.Results())) @@ -65,6 +71,11 @@ func PageToRest(page *entity.Page) openapi.Page { ID: page.ID, URL: page.URL, Created: page.Created, + Meta: openapi.PageMeta{ + Title: html.EscapeString(page.Meta.Title), + Description: html.EscapeString(page.Meta.Description), + Error: openapi.NewOptString(page.Meta.Error), + }, Formats: func() []openapi.Format { res := make([]openapi.Format, len(page.Formats)) diff --git a/ports/rest/service.go b/ports/rest/service.go index bb9ad16..794e5dd 100644 --- a/ports/rest/service.go +++ b/ports/rest/service.go @@ -20,14 +20,19 @@ type Pages interface { GetFile(ctx context.Context, pageID, fileID uuid.UUID) (*entity.File, error) } -func NewService(sites Pages, ch chan *entity.Page) *Service { - return &Service{pages: sites, ch: ch} +func NewService(pages Pages, ch chan *entity.Page, processor entity.Processor) *Service { + return &Service{ + pages: pages, + ch: ch, + processor: processor, + } } type Service struct { openapi.UnimplementedHandler - pages Pages - ch chan *entity.Page + pages Pages + ch chan *entity.Page + processor entity.Processor } func (s *Service) GetPage(ctx context.Context, params openapi.GetPageParams) (openapi.GetPageRes, error) { @@ -78,6 +83,13 @@ func (s *Service) AddPage(ctx context.Context, req openapi.OptAddPageReq, params page := entity.NewPage(url, description, domainFormats...) page.Status = entity.StatusProcessing + meta, err := s.processor.GetMeta(ctx, page.URL) + if err != nil { + page.Meta.Error = err.Error() + } else { + page.Meta = meta + } + if err := s.pages.Save(ctx, page); err != nil { return nil, fmt.Errorf("save page: %w", err) } diff --git a/ports/rest/ui.go b/ports/rest/ui.go index e6b33f5..ac2514e 100644 --- a/ports/rest/ui.go +++ b/ports/rest/ui.go @@ -10,15 +10,19 @@ import ( ) func NewUI(cfg config.UI) *UI { - return &UI{prefix: cfg.Prefix} + return &UI{ + prefix: cfg.Prefix, + theme: cfg.Theme, + } } type UI struct { prefix string + theme string } func (u *UI) ServeHTTP(w http.ResponseWriter, r *http.Request) { - serveRoot, err := fs.Sub(ui.StaticFiles, "static") + serveRoot, err := fs.Sub(ui.StaticFiles, u.theme) if err != nil { w.WriteHeader(http.StatusInternalServerError) return @@ -27,12 +31,11 @@ func (u *UI) ServeHTTP(w http.ResponseWriter, r *http.Request) { if strings.HasPrefix(r.URL.Path, u.prefix) { r.URL.Path = "/" + strings.TrimPrefix(r.URL.Path, u.prefix) } + if !strings.HasPrefix(r.URL.Path, "/static") { + r.URL.Path = "/" + } r.URL.Path = strings.TrimPrefix(r.URL.Path, "/static") http.FileServer(http.FS(serveRoot)).ServeHTTP(w, r) } - -func (u *UI) IsUIRequest(r *http.Request) bool { - return r.URL.Path == u.prefix || strings.HasPrefix(r.URL.Path, "/static/") -} diff --git a/ui/basic/index.html b/ui/basic/index.html new file mode 100644 index 0000000..27173c4 --- /dev/null +++ b/ui/basic/index.html @@ -0,0 +1,47 @@ + + + + + + + WebArchive + + + + + + + + + + + +

+ +
+ None +
+ + + diff --git a/ui/basic/lib.js b/ui/basic/lib.js new file mode 100644 index 0000000..0de648e --- /dev/null +++ b/ui/basic/lib.js @@ -0,0 +1,2 @@ +/*! jQuery v3.6.4 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(C,e){"use strict";var t=[],r=Object.getPrototypeOf,s=t.slice,g=t.flat?function(e){return t.flat.call(e)}:function(e){return t.concat.apply([],e)},u=t.push,i=t.indexOf,n={},o=n.toString,y=n.hasOwnProperty,a=y.toString,l=a.call(Object),v={},m=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},x=function(e){return null!=e&&e===e.window},E=C.document,c={type:!0,src:!0,nonce:!0,noModule:!0};function b(e,t,n){var r,i,o=(n=n||E).createElement("script");if(o.text=e,t)for(r in c)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function w(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[o.call(e)]||"object":typeof e}var f="3.6.4",S=function(e,t){return new S.fn.init(e,t)};function p(e){var t=!!e&&"length"in e&&e.length,n=w(e);return!m(e)&&!x(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+M+")"+M+"*"),U=new RegExp(M+"|>"),X=new RegExp(F),V=new RegExp("^"+I+"$"),G={ID:new RegExp("^#("+I+")"),CLASS:new RegExp("^\\.("+I+")"),TAG:new RegExp("^("+I+"|[*])"),ATTR:new RegExp("^"+W),PSEUDO:new RegExp("^"+F),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+M+"*(even|odd|(([+-]|)(\\d*)n|)"+M+"*(?:([+-]|)"+M+"*(\\d+)|))"+M+"*\\)|)","i"),bool:new RegExp("^(?:"+R+")$","i"),needsContext:new RegExp("^"+M+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+M+"*((?:-\\d)?\\d*)"+M+"*\\)|)(?=[^-]|$)","i")},Y=/HTML$/i,Q=/^(?:input|select|textarea|button)$/i,J=/^h\d$/i,K=/^[^{]+\{\s*\[native \w/,Z=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,ee=/[+~]/,te=new RegExp("\\\\[\\da-fA-F]{1,6}"+M+"?|\\\\([^\\r\\n\\f])","g"),ne=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},re=/([\0-\x1f\x7f]|^-?\d)|^-$|[^\0-\x1f\x7f-\uFFFF\w-]/g,ie=function(e,t){return t?"\0"===e?"\ufffd":e.slice(0,-1)+"\\"+e.charCodeAt(e.length-1).toString(16)+" ":"\\"+e},oe=function(){T()},ae=be(function(e){return!0===e.disabled&&"fieldset"===e.nodeName.toLowerCase()},{dir:"parentNode",next:"legend"});try{H.apply(t=O.call(p.childNodes),p.childNodes),t[p.childNodes.length].nodeType}catch(e){H={apply:t.length?function(e,t){L.apply(e,O.call(t))}:function(e,t){var n=e.length,r=0;while(e[n++]=t[r++]);e.length=n-1}}}function se(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(T(e),e=e||C,E)){if(11!==p&&(u=Z.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return n.push(a),n}else if(f&&(a=f.getElementById(i))&&v(e,a)&&a.id===i)return n.push(a),n}else{if(u[2])return H.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&d.getElementsByClassName&&e.getElementsByClassName)return H.apply(n,e.getElementsByClassName(i)),n}if(d.qsa&&!N[t+" "]&&(!y||!y.test(t))&&(1!==p||"object"!==e.nodeName.toLowerCase())){if(c=t,f=e,1===p&&(U.test(t)||z.test(t))){(f=ee.test(t)&&ve(e.parentNode)||e)===e&&d.scope||((s=e.getAttribute("id"))?s=s.replace(re,ie):e.setAttribute("id",s=S)),o=(l=h(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+xe(l[o]);c=l.join(",")}try{return H.apply(n,f.querySelectorAll(c)),n}catch(e){N(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return g(t.replace(B,"$1"),e,n,r)}function ue(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function le(e){return e[S]=!0,e}function ce(e){var t=C.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function fe(e,t){var n=e.split("|"),r=n.length;while(r--)b.attrHandle[n[r]]=t}function pe(e,t){var n=t&&e,r=n&&1===e.nodeType&&1===t.nodeType&&e.sourceIndex-t.sourceIndex;if(r)return r;if(n)while(n=n.nextSibling)if(n===t)return-1;return e?1:-1}function de(t){return function(e){return"input"===e.nodeName.toLowerCase()&&e.type===t}}function he(n){return function(e){var t=e.nodeName.toLowerCase();return("input"===t||"button"===t)&&e.type===n}}function ge(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&ae(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function ye(a){return le(function(o){return o=+o,le(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function ve(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}for(e in d=se.support={},i=se.isXML=function(e){var t=e&&e.namespaceURI,n=e&&(e.ownerDocument||e).documentElement;return!Y.test(t||n&&n.nodeName||"HTML")},T=se.setDocument=function(e){var t,n,r=e?e.ownerDocument||e:p;return r!=C&&9===r.nodeType&&r.documentElement&&(a=(C=r).documentElement,E=!i(C),p!=C&&(n=C.defaultView)&&n.top!==n&&(n.addEventListener?n.addEventListener("unload",oe,!1):n.attachEvent&&n.attachEvent("onunload",oe)),d.scope=ce(function(e){return a.appendChild(e).appendChild(C.createElement("div")),"undefined"!=typeof e.querySelectorAll&&!e.querySelectorAll(":scope fieldset div").length}),d.cssHas=ce(function(){try{return C.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),d.attributes=ce(function(e){return e.className="i",!e.getAttribute("className")}),d.getElementsByTagName=ce(function(e){return e.appendChild(C.createComment("")),!e.getElementsByTagName("*").length}),d.getElementsByClassName=K.test(C.getElementsByClassName),d.getById=ce(function(e){return a.appendChild(e).id=S,!C.getElementsByName||!C.getElementsByName(S).length}),d.getById?(b.filter.ID=function(e){var t=e.replace(te,ne);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(te,ne);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&E){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=d.getElementsByTagName?function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):d.qsa?t.querySelectorAll(e):void 0}:function(e,t){var n,r=[],i=0,o=t.getElementsByTagName(e);if("*"===e){while(n=o[i++])1===n.nodeType&&r.push(n);return r}return o},b.find.CLASS=d.getElementsByClassName&&function(e,t){if("undefined"!=typeof t.getElementsByClassName&&E)return t.getElementsByClassName(e)},s=[],y=[],(d.qsa=K.test(C.querySelectorAll))&&(ce(function(e){var t;a.appendChild(e).innerHTML="",e.querySelectorAll("[msallowcapture^='']").length&&y.push("[*^$]="+M+"*(?:''|\"\")"),e.querySelectorAll("[selected]").length||y.push("\\["+M+"*(?:value|"+R+")"),e.querySelectorAll("[id~="+S+"-]").length||y.push("~="),(t=C.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||y.push("\\["+M+"*name"+M+"*="+M+"*(?:''|\"\")"),e.querySelectorAll(":checked").length||y.push(":checked"),e.querySelectorAll("a#"+S+"+*").length||y.push(".#.+[+~]"),e.querySelectorAll("\\\f"),y.push("[\\r\\n\\f]")}),ce(function(e){e.innerHTML="";var t=C.createElement("input");t.setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),e.querySelectorAll("[name=d]").length&&y.push("name"+M+"*[*^$|!~]?="),2!==e.querySelectorAll(":enabled").length&&y.push(":enabled",":disabled"),a.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&y.push(":enabled",":disabled"),e.querySelectorAll("*,:x"),y.push(",.*:")})),(d.matchesSelector=K.test(c=a.matches||a.webkitMatchesSelector||a.mozMatchesSelector||a.oMatchesSelector||a.msMatchesSelector))&&ce(function(e){d.disconnectedMatch=c.call(e,"*"),c.call(e,"[s!='']:x"),s.push("!=",F)}),d.cssHas||y.push(":has"),y=y.length&&new RegExp(y.join("|")),s=s.length&&new RegExp(s.join("|")),t=K.test(a.compareDocumentPosition),v=t||K.test(a.contains)?function(e,t){var n=9===e.nodeType&&e.documentElement||e,r=t&&t.parentNode;return e===r||!(!r||1!==r.nodeType||!(n.contains?n.contains(r):e.compareDocumentPosition&&16&e.compareDocumentPosition(r)))}:function(e,t){if(t)while(t=t.parentNode)if(t===e)return!0;return!1},j=t?function(e,t){if(e===t)return l=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!d.sortDetached&&t.compareDocumentPosition(e)===n?e==C||e.ownerDocument==p&&v(p,e)?-1:t==C||t.ownerDocument==p&&v(p,t)?1:u?P(u,e)-P(u,t):0:4&n?-1:1)}:function(e,t){if(e===t)return l=!0,0;var n,r=0,i=e.parentNode,o=t.parentNode,a=[e],s=[t];if(!i||!o)return e==C?-1:t==C?1:i?-1:o?1:u?P(u,e)-P(u,t):0;if(i===o)return pe(e,t);n=e;while(n=n.parentNode)a.unshift(n);n=t;while(n=n.parentNode)s.unshift(n);while(a[r]===s[r])r++;return r?pe(a[r],s[r]):a[r]==p?-1:s[r]==p?1:0}),C},se.matches=function(e,t){return se(e,null,null,t)},se.matchesSelector=function(e,t){if(T(e),d.matchesSelector&&E&&!N[t+" "]&&(!s||!s.test(t))&&(!y||!y.test(t)))try{var n=c.call(e,t);if(n||d.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){N(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(te,ne),e[3]=(e[3]||e[4]||e[5]||"").replace(te,ne),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||se.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&se.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return G.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&X.test(n)&&(t=h(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(te,ne).toLowerCase();return"*"===e?function(){return!0}:function(e){return e.nodeName&&e.nodeName.toLowerCase()===t}},CLASS:function(e){var t=m[e+" "];return t||(t=new RegExp("(^|"+M+")"+e+"("+M+"|$)"))&&m(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=se.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function j(e,n,r){return m(n)?S.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?S.grep(e,function(e){return e===n!==r}):"string"!=typeof n?S.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(S.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||D,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:q.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof S?t[0]:t,S.merge(this,S.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:E,!0)),N.test(r[1])&&S.isPlainObject(t))for(r in t)m(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=E.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):m(e)?void 0!==n.ready?n.ready(e):e(S):S.makeArray(e,this)}).prototype=S.fn,D=S(E);var L=/^(?:parents|prev(?:Until|All))/,H={children:!0,contents:!0,next:!0,prev:!0};function O(e,t){while((e=e[t])&&1!==e.nodeType);return e}S.fn.extend({has:function(e){var t=S(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,he=/^$|^module$|\/(?:java|ecma)script/i;ce=E.createDocumentFragment().appendChild(E.createElement("div")),(fe=E.createElement("input")).setAttribute("type","radio"),fe.setAttribute("checked","checked"),fe.setAttribute("name","t"),ce.appendChild(fe),v.checkClone=ce.cloneNode(!0).cloneNode(!0).lastChild.checked,ce.innerHTML="",v.noCloneChecked=!!ce.cloneNode(!0).lastChild.defaultValue,ce.innerHTML="",v.option=!!ce.lastChild;var ge={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function ye(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&A(e,t)?S.merge([e],n):n}function ve(e,t){for(var n=0,r=e.length;n",""]);var me=/<|&#?\w+;/;function xe(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function je(e,t){return A(e,"table")&&A(11!==t.nodeType?t:t.firstChild,"tr")&&S(e).children("tbody")[0]||e}function De(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function qe(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Le(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(Y.hasData(e)&&(s=Y.get(e).events))for(i in Y.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),E.head.appendChild(r[0])},abort:function(){i&&i()}}});var Ut,Xt=[],Vt=/(=)\?(?=&|$)|\?\?/;S.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Xt.pop()||S.expando+"_"+Ct.guid++;return this[e]=!0,e}}),S.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Vt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Vt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=m(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Vt,"$1"+r):!1!==e.jsonp&&(e.url+=(Et.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||S.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=C[r],C[r]=function(){o=arguments},n.always(function(){void 0===i?S(C).removeProp(r):C[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Xt.push(r)),o&&m(i)&&i(o[0]),o=i=void 0}),"script"}),v.createHTMLDocument=((Ut=E.implementation.createHTMLDocument("").body).innerHTML="
",2===Ut.childNodes.length),S.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(v.createHTMLDocument?((r=(t=E.implementation.createHTMLDocument("")).createElement("base")).href=E.location.href,t.head.appendChild(r)):t=E),o=!n&&[],(i=N.exec(e))?[t.createElement(i[1])]:(i=xe([e],t,o),o&&o.length&&S(o).remove(),S.merge([],i.childNodes)));var r,i,o},S.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(S.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},S.expr.pseudos.animated=function(t){return S.grep(S.timers,function(e){return t===e.elem}).length},S.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=S.css(e,"position"),c=S(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=S.css(e,"top"),u=S.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),m(t)&&(t=t.call(e,n,S.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},S.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){S.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===S.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===S.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=S(e).offset()).top+=S.css(e,"borderTopWidth",!0),i.left+=S.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-S.css(r,"marginTop",!0),left:t.left-i.left-S.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===S.css(e,"position"))e=e.offsetParent;return e||re})}}),S.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;S.fn[t]=function(e){return B(this,function(e,t,n){var r;if(x(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),S.each(["top","left"],function(e,n){S.cssHooks[n]=_e(v.pixelPosition,function(e,t){if(t)return t=Be(e,n),Pe.test(t)?S(e).position()[n]+"px":t})}),S.each({Height:"height",Width:"width"},function(a,s){S.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){S.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return B(this,function(e,t,n){var r;return x(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?S.css(e,t,i):S.style(e,t,n,i)},s,n?e:void 0,n)}})}),S.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){S.fn[t]=function(e){return this.on(t,e)}}),S.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.mouseenter(e).mouseleave(t||e)}}),S.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){S.fn[n]=function(e,t){return 0 - - - - - - Document - - - -

Hello World!

- - - diff --git a/ui/static/style.css b/ui/static/style.css deleted file mode 100644 index 8b7c591..0000000 --- a/ui/static/style.css +++ /dev/null @@ -1,3 +0,0 @@ -h1 { - background-color: azure; -}