Compare commits
6 Commits
ff0ab4c2c9
...
965ea28884
| Author | SHA1 | Date | |
|---|---|---|---|
|
965ea28884
|
|||
|
daf4844bcb
|
|||
|
e438c0c850
|
|||
|
8d75630f5c
|
|||
|
aecb142a26
|
|||
|
b0dce3c29f
|
13
editor/assets/assets.go
Normal file
13
editor/assets/assets.go
Normal file
@@ -0,0 +1,13 @@
|
||||
package assets
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
)
|
||||
|
||||
//go:embed css/style.css js/*.js ipub/*.js ipub/*.css
|
||||
var files embed.FS
|
||||
|
||||
func New() fs.FS {
|
||||
return files
|
||||
}
|
||||
225
editor/assets/css/style.css
Normal file
225
editor/assets/css/style.css
Normal file
@@ -0,0 +1,225 @@
|
||||
/*! tailwindcss v4.1.17 | MIT License | https://tailwindcss.com */
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||
--color-gray-600: oklch(44.6% 0.03 256.802);
|
||||
--color-gray-700: oklch(37.3% 0.034 259.733);
|
||||
--color-gray-900: oklch(21% 0.034 264.665);
|
||||
--spacing: 0.25rem;
|
||||
--radius-md: 0.375rem;
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
::-webkit-calendar-picker-indicator {
|
||||
line-height: 1;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.static {
|
||||
position: static;
|
||||
}
|
||||
.contents {
|
||||
display: contents;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.resize {
|
||||
resize: both;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.bg-gray-900 {
|
||||
background-color: var(--color-gray-900);
|
||||
}
|
||||
.text-gray-50 {
|
||||
color: var(--color-gray-50);
|
||||
}
|
||||
.has-\[\#first-publication\]\:h-full {
|
||||
&:has(*:is(#first-publication)) {
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
.has-\[\#first-publication\]\:h-svw {
|
||||
&:has(*:is(#first-publication)) {
|
||||
height: 100svw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
form {
|
||||
input {
|
||||
background-color: var(--color-gray-700);
|
||||
border-radius: var(--radius-md);
|
||||
padding-inline-start: calc(var(--spacing) * 2);
|
||||
&:has(+ button) {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-gray-600);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 calc(var(--spacing) * 2);
|
||||
input + & {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
22
editor/assets/css/tailwind.css
Normal file
22
editor/assets/css/tailwind.css
Normal file
@@ -0,0 +1,22 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
form {
|
||||
input {
|
||||
background-color: var(--color-gray-700);
|
||||
border-radius: var(--radius-md);
|
||||
padding-inline-start: --spacing(2);
|
||||
&:has(+ button) {
|
||||
border-radius: var(--radius-md) 0 0 var(--radius-md);
|
||||
}
|
||||
}
|
||||
button {
|
||||
background-color: var(--color-gray-600);
|
||||
border-radius: var(--radius-md);
|
||||
padding: 0 --spacing(2);
|
||||
input + & {
|
||||
border-radius: 0 var(--radius-md) var(--radius-md) 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
128
editor/cmd/cmd.go
Normal file
128
editor/cmd/cmd.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/assets"
|
||||
"code.capytal.cc/capytal/comicverse/editor/router"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/capytal/comicverse/editor/template"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
)
|
||||
|
||||
var (
|
||||
hostname = flag.String("hostname", "localhost", "Host to listen to")
|
||||
port = flag.Uint("port", 8080, "Port to be used for the server.")
|
||||
verbose = flag.Bool("verbose", false, "Print debug information on logs")
|
||||
dev = flag.Bool("dev", false, "Run the server in debug mode.")
|
||||
)
|
||||
|
||||
var (
|
||||
storageDir = getEnv("EDITOR_PUBLICATIONS_DIR", ".publications") // TODO: Use XDG_STATE_HOME as default
|
||||
assetsDir = getEnv("EDITOR_ASSETS_DIR", "assets") // TODO: Use XDG_CONFIG_HOME as default
|
||||
templatesDir = getEnv("EDITOR_TEMPLATES_DIR", "template") // TODO: Use XDG_CONFIG_HOME as default
|
||||
)
|
||||
|
||||
func getEnv(key string, d string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
return d
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func init() {
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
log := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{}))
|
||||
assert := tinyssert.New(tinyssert.WithLogger(log))
|
||||
|
||||
assets := assets.New()
|
||||
templater, err := template.New()
|
||||
if err != nil {
|
||||
log.Error("Unable to initiate templater due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
if *dev {
|
||||
assets = os.DirFS(assetsDir)
|
||||
log = slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelDebug}))
|
||||
assert = tinyssert.New(tinyssert.WithPanic(), tinyssert.WithLogger(log.WithGroup("assertions")))
|
||||
templater, err = template.Dev(os.DirFS(templatesDir))
|
||||
if err != nil {
|
||||
log.Error("Unable to initiate dev templater due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
err = os.MkdirAll(storageDir, os.ModePerm)
|
||||
if err != nil {
|
||||
log.Error("Unable to create storage directory due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
root, err := os.OpenRoot(storageDir)
|
||||
if err != nil {
|
||||
log.Error("Unable to open storage directory due to error", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
return
|
||||
}
|
||||
|
||||
storage := storage.Newlocal(root, log)
|
||||
|
||||
editor := editor.New(storage, log.WithGroup("editor"), assert)
|
||||
|
||||
router := router.New(router.Config{
|
||||
Assets: assets,
|
||||
Editor: editor,
|
||||
Templater: templater,
|
||||
Logger: log.WithGroup("router"),
|
||||
})
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: fmt.Sprintf("%s:%d", *hostname, *port),
|
||||
Handler: router,
|
||||
}
|
||||
|
||||
c, stop := signal.NotifyContext(ctx, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go func() {
|
||||
log.Info("Starting application",
|
||||
slog.String("host", *hostname),
|
||||
slog.Uint64("port", uint64(*port)),
|
||||
slog.Bool("verbose", *verbose),
|
||||
slog.Bool("development", *dev))
|
||||
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
log.Error("Failed to start application server", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
|
||||
<-c.Done()
|
||||
|
||||
log.Info("Stopping application gracefully")
|
||||
if err := srv.Shutdown(c); err != nil {
|
||||
log.Error("Failed to stop application server gracefully", slog.String("error", err.Error()))
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
log.Info("FINAL")
|
||||
os.Exit(0)
|
||||
}
|
||||
69
editor/container.go
Normal file
69
editor/container.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
_ "embed"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/epub"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/net/html"
|
||||
)
|
||||
|
||||
type Container struct {
|
||||
id uuid.UUID
|
||||
|
||||
pkg epub.Package
|
||||
storage storage.Storage
|
||||
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
|
||||
flushed bool
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
func (p *Container) Flush() error {
|
||||
p.assert.NotZero(p.pkg, "invalid ePUB: package must be set")
|
||||
p.assert.NotZero(p.pkg.Metadata, "invalid ePUB: package must have metadata")
|
||||
p.assert.NotZero(p.pkg.Metadata.ID, "invalid ePUB: ID must always be specified")
|
||||
p.assert.NotZero(p.pkg.Metadata.Language, "invalid ePUB: Language must always be specified")
|
||||
p.assert.NotZero(p.pkg.Metadata.Title, "invalid ePUB: Title must always be specified")
|
||||
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
|
||||
p.log.Debug("Flushing state of publication")
|
||||
|
||||
if p.flushed {
|
||||
p.log.Debug("Publication doesn't have unsaved changes, skipping flush")
|
||||
return nil
|
||||
}
|
||||
|
||||
defer p.log.Debug("Publication's state flushed")
|
||||
|
||||
b, err := xml.MarshalIndent(p.pkg, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("editor.Publication: failed to marshal package: %w", err)
|
||||
}
|
||||
|
||||
if _, err = p.storage.Write("content.opf", b); err != nil {
|
||||
return fmt.Errorf("editor.Publication: failed to write content.opf: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
114
editor/editor.go
Normal file
114
editor/editor.go
Normal file
@@ -0,0 +1,114 @@
|
||||
package editor
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/epub"
|
||||
"code.capytal.cc/capytal/comicverse/editor/storage"
|
||||
"code.capytal.cc/loreddev/x/tinyssert"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
func New(
|
||||
storage storage.Storage,
|
||||
logger *slog.Logger,
|
||||
assert tinyssert.Assertions,
|
||||
) *Editor {
|
||||
assert.NotZero(storage)
|
||||
assert.NotZero(logger)
|
||||
|
||||
return &Editor{
|
||||
storage: storage,
|
||||
|
||||
log: logger,
|
||||
assert: assert,
|
||||
}
|
||||
}
|
||||
|
||||
type Editor struct {
|
||||
storage storage.Storage
|
||||
|
||||
ctx context.Context
|
||||
log *slog.Logger
|
||||
assert tinyssert.Assertions
|
||||
}
|
||||
|
||||
func (e *Editor) New(id uuid.UUID, title string, lang language.Tag) (*Container, error) {
|
||||
f := fmt.Sprintf("%s/content.opf", id)
|
||||
if e.storage.Exists(f) {
|
||||
return nil, ErrAlreadyExists
|
||||
}
|
||||
|
||||
pub := &Container{
|
||||
id: id,
|
||||
|
||||
pkg: epub.Package{
|
||||
Metadata: epub.Metadata{
|
||||
ID: fmt.Sprintf("comicverse:%s", id),
|
||||
Title: title,
|
||||
Language: lang,
|
||||
Date: time.Now(),
|
||||
Modified: time.Now(),
|
||||
},
|
||||
},
|
||||
|
||||
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
|
||||
assert: e.assert,
|
||||
|
||||
storage: storage.WithRoot(id.String(), e.storage),
|
||||
}
|
||||
|
||||
err := pub.Flush()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to flush changes of publication: %w", err)
|
||||
}
|
||||
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
func (e *Editor) Open(id uuid.UUID) (*Container, error) {
|
||||
content, err := e.storage.Open(fmt.Sprintf("%s/content.opf", id))
|
||||
if errors.Is(err, storage.ErrNotExists) {
|
||||
return nil, ErrNotExists
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to open package: %w", err)
|
||||
}
|
||||
|
||||
b, err := io.ReadAll(content)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to read contents of package: %w", err)
|
||||
}
|
||||
|
||||
var pkg epub.Package
|
||||
|
||||
err = xml.Unmarshal(b, &pkg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("editor: unable to decode xml of package: %w", err)
|
||||
}
|
||||
|
||||
c := &Container{
|
||||
id: id,
|
||||
|
||||
pkg: pkg,
|
||||
|
||||
log: e.log.WithGroup(fmt.Sprintf("publication:%s", id)),
|
||||
assert: e.assert,
|
||||
|
||||
storage: storage.WithRoot(id.String(), e.storage),
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
var (
|
||||
ErrAlreadyExists = errors.New("editor: file already exists")
|
||||
ErrNotExists = errors.New("editor: file doesn't exist")
|
||||
)
|
||||
45
editor/epub/epub.go
Normal file
45
editor/epub/epub.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package epub
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type Meta struct {
|
||||
Attributes map[string]string `xml:"-"`
|
||||
Value string `xml:",chardata"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = Meta{}
|
||||
_ xml.Unmarshaler = (*Meta)(nil)
|
||||
)
|
||||
|
||||
func (m Meta) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
for n, v := range m.Attributes {
|
||||
start.Attr = append(start.Attr, xml.Attr{
|
||||
Name: xml.Name{Local: n},
|
||||
Value: v,
|
||||
})
|
||||
}
|
||||
return e.EncodeElement(m.Value, start)
|
||||
}
|
||||
|
||||
func (m *Meta) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if m == nil {
|
||||
m = &Meta{}
|
||||
}
|
||||
if m.Attributes == nil {
|
||||
m.Attributes = map[string]string{}
|
||||
}
|
||||
|
||||
for _, attr := range start.Attr {
|
||||
m.Attributes[attr.Name.Local] = attr.Value
|
||||
}
|
||||
|
||||
if err := d.DecodeElement(&m.Value, &start); err != nil {
|
||||
return fmt.Errorf("epub.Meta: failed to decode chardata: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
450
editor/epub/opf.go
Normal file
450
editor/epub/opf.go
Normal file
@@ -0,0 +1,450 @@
|
||||
package epub
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/shortid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type Package struct {
|
||||
Metadata Metadata `xml:"metadata"`
|
||||
Manifest Manisfest `xml:"manifest"`
|
||||
Spine Spine `xml:"spine"`
|
||||
|
||||
// TODO: Collections https://www.w3.org/TR/epub-33/#sec-pkg-collections
|
||||
}
|
||||
|
||||
var _ xml.Marshaler = Package{}
|
||||
|
||||
func (p Package) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
start.Name = xml.Name{
|
||||
Local: "package",
|
||||
Space: "http://www.idpf.org/2007/opf",
|
||||
}
|
||||
|
||||
start.Attr = append(start.Attr, []xml.Attr{
|
||||
{Name: xml.Name{Local: "xmlns:dc"}, Value: "http://purl.org/dc/elements/1.1/"},
|
||||
{Name: xml.Name{Local: "xmlns:dcterms"}, Value: "http://purl.org/dc/terms/"},
|
||||
{Name: xml.Name{Local: "xmlns:opf"}, Value: "http://www.idpf.org/2007/opf"},
|
||||
{Name: xml.Name{Local: "unique-identifier"}, Value: uniqueIdentifierID},
|
||||
{Name: xml.Name{Local: "version"}, Value: "3.0"},
|
||||
}...)
|
||||
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err := e.EncodeElement(p.Metadata, xml.StartElement{Name: xml.Name{Local: "metadata"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.EncodeElement(p.Manifest, xml.StartElement{Name: xml.Name{Local: "manifest"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = e.EncodeElement(p.Spine, xml.StartElement{Name: xml.Name{Local: "spine"}})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return e.EncodeToken(xml.EndElement{Name: start.Name})
|
||||
}
|
||||
|
||||
type Metadata struct {
|
||||
ID string `xml:"dc:identifier"`
|
||||
Title string `xml:"dc:title"`
|
||||
Language language.Tag `xml:"dc:language"`
|
||||
Creators []Person `xml:"dc:creator"`
|
||||
Contributors []Person `xml:"dc:contributor"`
|
||||
Date time.Time `xml:"dc:date"`
|
||||
Modified time.Time `xml:"-"`
|
||||
|
||||
// TODO: Support for dc:subject, dc:type and meta elements
|
||||
// https://www.w3.org/TR/epub-33/#sec-opf-dcsubject
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = Metadata{}
|
||||
_ xml.Unmarshaler = (*Metadata)(nil)
|
||||
)
|
||||
|
||||
func (m Metadata) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
helper := encoderHelper(e, "epub.Metadata")
|
||||
|
||||
err := helper("dc:identifier", m.ID, xml.Attr{
|
||||
Name: xml.Name{Local: "id"}, Value: uniqueIdentifierID,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err = helper("dc:title", m.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if err = helper("dc:language", m.Language.String()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, creator := range m.Creators {
|
||||
err := creator.marshalIntoRootXML(xml.Name{Local: "dc:creator"}, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
for _, contributor := range m.Contributors {
|
||||
err := contributor.marshalIntoRootXML(xml.Name{Local: "dc:contributor"}, e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !m.Date.IsZero() {
|
||||
if err = helper("dc:date", m.Date.Format(time.RFC3339)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if !m.Modified.IsZero() {
|
||||
if err = helper("meta", m.Modified.Format(time.RFC3339), xml.Attr{
|
||||
Name: xml.Name{Local: "property"}, Value: "dcterms:modified",
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return e.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
func (m *Metadata) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if m == nil {
|
||||
m = &Metadata{}
|
||||
}
|
||||
|
||||
var v struct {
|
||||
ID string `xml:"http://purl.org/dc/elements/1.1/ identifier"`
|
||||
Title string `xml:"http://purl.org/dc/elements/1.1/ title"`
|
||||
Language language.Tag `xml:"http://purl.org/dc/elements/1.1/ language"`
|
||||
Creators []Person `xml:"http://purl.org/dc/elements/1.1/ creator"`
|
||||
Contributors []Person `xml:"http://purl.org/dc/elements/1.1/ contributor"`
|
||||
Date string `xml:"http://purl.org/dc/elements/1.1/ date"`
|
||||
|
||||
Meta []Meta `xml:"meta"`
|
||||
}
|
||||
|
||||
if err := d.DecodeElement(&v, &start); err != nil {
|
||||
return fmt.Errorf("epub.Metadata: unable to unmarshal: %w", err)
|
||||
}
|
||||
|
||||
m.ID = v.ID
|
||||
m.Title = v.Title
|
||||
m.Language = v.Language
|
||||
|
||||
if v.Date != "" {
|
||||
t, err := time.Parse(time.RFC3339, v.Date)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: date is not valid: %w", err)
|
||||
}
|
||||
m.Date = t
|
||||
}
|
||||
|
||||
m.Creators = v.Creators
|
||||
|
||||
for i, c := range m.Creators {
|
||||
c, err := c.unmarshalFromMetas(v.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
||||
}
|
||||
m.Creators[i] = c
|
||||
}
|
||||
|
||||
m.Contributors = v.Contributors
|
||||
|
||||
for i, c := range m.Contributors {
|
||||
c, err := c.unmarshalFromMetas(v.Meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: invalid creator metadata %q: %w", c.Name, err)
|
||||
}
|
||||
m.Contributors[i] = c
|
||||
}
|
||||
|
||||
for _, meta := range v.Meta {
|
||||
if property, ok := meta.Attributes["property"]; ok {
|
||||
switch property {
|
||||
case "dcterms:modified":
|
||||
t, err := time.Parse(time.RFC3339, meta.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("epub.Metadata: modified date is not valid: %w", err)
|
||||
}
|
||||
m.Modified = t
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var uniqueIdentifierID = "pub-id"
|
||||
|
||||
type Person struct {
|
||||
ID string `xml:"id,attr"`
|
||||
Name string `xml:",chardata"`
|
||||
Role string `xml:"-"`
|
||||
FileAs string `xml:"-"`
|
||||
|
||||
AlternateScripts map[language.Tag]string `xml:"-"`
|
||||
}
|
||||
|
||||
func (p Person) marshalIntoRootXML(name xml.Name, e *xml.Encoder) error {
|
||||
if p.ID == "" {
|
||||
p.ID = shortid.New().String()
|
||||
}
|
||||
|
||||
err := e.EncodeElement(p.Name, xml.StartElement{
|
||||
Name: name,
|
||||
Attr: []xml.Attr{{Name: xml.Name{Local: "id"}, Value: p.ID}},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for lang, name := range p.AlternateScripts {
|
||||
err = e.EncodeElement(name, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "alternate-script"},
|
||||
{Name: xml.Name{Local: "xml:lang"}, Value: lang.String()},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p.FileAs != "" {
|
||||
err = e.EncodeElement(p.FileAs, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "file-as"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if p.Role != "" {
|
||||
err = e.EncodeElement(p.Role, xml.StartElement{
|
||||
Name: xml.Name{Local: "meta"},
|
||||
Attr: []xml.Attr{
|
||||
{Name: xml.Name{Local: "refines"}, Value: fmt.Sprintf("#%s", p.ID)},
|
||||
{Name: xml.Name{Local: "property"}, Value: "role"},
|
||||
{Name: xml.Name{Local: "scheme"}, Value: "marc:relators"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p Person) unmarshalFromMetas(metaList []Meta) (Person, error) {
|
||||
if p.ID == "" {
|
||||
return p, nil
|
||||
}
|
||||
if p.AlternateScripts == nil {
|
||||
p.AlternateScripts = map[language.Tag]string{}
|
||||
}
|
||||
|
||||
for _, meta := range metaList {
|
||||
refines, ok := meta.Attributes["refines"]
|
||||
if !ok || refines != fmt.Sprintf("#%s", p.ID) {
|
||||
continue
|
||||
}
|
||||
|
||||
property, ok := meta.Attributes["property"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
switch property {
|
||||
case "alternate-script":
|
||||
l, ok := meta.Attributes["lang"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
lang, err := language.Parse(l)
|
||||
if err != nil {
|
||||
return p, fmt.Errorf("epub.Person: language %q is not valid: %w", l, err)
|
||||
}
|
||||
p.AlternateScripts[lang] = meta.Value
|
||||
case "file-as":
|
||||
p.FileAs = meta.Value
|
||||
case "role":
|
||||
p.Role = meta.Value
|
||||
}
|
||||
}
|
||||
|
||||
return p, nil
|
||||
}
|
||||
|
||||
type Manisfest struct {
|
||||
Items []Item `xml:"item"`
|
||||
}
|
||||
|
||||
type Item struct {
|
||||
ID string `xml:"id,attr"`
|
||||
HRef string `xml:"href,attr"`
|
||||
MediaType string `xml:"media-type,attr"`
|
||||
MediaOverlay string `xml:"media-overlay,attr,omitempty"`
|
||||
Properties ItemProperties `xml:"properties,attr,omitempty"`
|
||||
}
|
||||
|
||||
type Spine struct {
|
||||
ID string `xml:"id,attr,omitempty"`
|
||||
Toc string `xml:"toc,attr,omitempty"`
|
||||
|
||||
PageProgressionDir PageProgressionDir `xml:"page-progression-direction,attr,omitempty"`
|
||||
|
||||
ItemRefs []ItemRef `xml:"itemref"`
|
||||
}
|
||||
|
||||
type PageProgressionDir string
|
||||
|
||||
const (
|
||||
PageProgressionDirDefault PageProgressionDir = "default"
|
||||
PageProgressionDirLTR PageProgressionDir = "ltr"
|
||||
PageProgressionDirRTL PageProgressionDir = "rtl"
|
||||
)
|
||||
|
||||
type ItemRef struct {
|
||||
IDRef string `xml:"idref,attr"`
|
||||
ID string `xml:"id,attr"`
|
||||
NotLinear bool `xml:"linear,attr"`
|
||||
Properties ItemProperties `xml:"properties,attr"`
|
||||
}
|
||||
|
||||
var (
|
||||
_ xml.Marshaler = ItemRef{}
|
||||
_ xml.Unmarshaler = (*ItemRef)(nil)
|
||||
)
|
||||
|
||||
func (ref ItemRef) MarshalXML(e *xml.Encoder, start xml.StartElement) error {
|
||||
linear := xml.Attr{Name: xml.Name{Local: "linear"}}
|
||||
if !ref.NotLinear {
|
||||
linear.Value = "no"
|
||||
} else {
|
||||
linear.Value = "yes"
|
||||
}
|
||||
|
||||
props, _ := ref.Properties.MarshalXMLAttr(xml.Name{Local: "properties"})
|
||||
|
||||
start.Attr = append(start.Attr, []xml.Attr{
|
||||
{Name: xml.Name{Local: "idref"}, Value: ref.IDRef},
|
||||
{Name: xml.Name{Local: "id"}, Value: ref.ID},
|
||||
linear,
|
||||
props,
|
||||
}...)
|
||||
|
||||
if err := e.EncodeToken(start); err != nil {
|
||||
return err
|
||||
}
|
||||
return e.EncodeToken(start.End())
|
||||
}
|
||||
|
||||
func (ref *ItemRef) UnmarshalXML(d *xml.Decoder, start xml.StartElement) error {
|
||||
if ref == nil {
|
||||
ref = &ItemRef{}
|
||||
}
|
||||
for _, attr := range start.Attr {
|
||||
switch attr.Name.Local {
|
||||
case "idref":
|
||||
ref.IDRef = attr.Value
|
||||
case "id":
|
||||
ref.ID = attr.Value
|
||||
case "linear":
|
||||
if attr.Value == "no" {
|
||||
ref.NotLinear = true
|
||||
} else {
|
||||
ref.NotLinear = false
|
||||
}
|
||||
case "properties":
|
||||
ref.Properties.UnmarshalXMLAttr(attr)
|
||||
}
|
||||
}
|
||||
var t string
|
||||
return d.DecodeElement(&t, &start)
|
||||
}
|
||||
|
||||
type (
|
||||
ItemProperty string
|
||||
ItemProperties []ItemProperty
|
||||
)
|
||||
|
||||
const (
|
||||
ItemPropertyCoverImage ItemProperty = "cover-image"
|
||||
ItemPropertyNav ItemProperty = "nav"
|
||||
ItemPropertyMathML ItemProperty = "mathml"
|
||||
ItemPropertyRemoteResources ItemProperty = "remote-resources"
|
||||
ItemPropertyScripted ItemProperty = "scripted"
|
||||
ItemPropertySVG ItemProperty = "svg"
|
||||
ItemPropertySwitch ItemProperty = "switch"
|
||||
)
|
||||
|
||||
var (
|
||||
_ xml.MarshalerAttr = (ItemProperties)(nil)
|
||||
_ xml.UnmarshalerAttr = (*ItemProperties)(nil)
|
||||
)
|
||||
|
||||
func (is ItemProperties) MarshalXMLAttr(name xml.Name) (xml.Attr, error) {
|
||||
strs := make([]string, len(is))
|
||||
for i := range is {
|
||||
strs[i] = string(is[i])
|
||||
}
|
||||
return xml.Attr{Name: name, Value: strings.Join(strs, " ")}, nil
|
||||
}
|
||||
|
||||
func (is *ItemProperties) UnmarshalXMLAttr(attr xml.Attr) error {
|
||||
if is == nil {
|
||||
is = &ItemProperties{}
|
||||
}
|
||||
for s := range strings.SplitSeq(attr.Value, " ") {
|
||||
*is = append(*is, ItemProperty(s))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func encoderHelper(e *xml.Encoder, errPrefix ...string) func(
|
||||
key string, value string, attrs ...xml.Attr,
|
||||
) error {
|
||||
if len(errPrefix) == 0 {
|
||||
errPrefix[0] = ""
|
||||
} else {
|
||||
errPrefix[0] = fmt.Sprintf("%s: ", errPrefix[0])
|
||||
}
|
||||
|
||||
return func(key string, value string, attrs ...xml.Attr) error {
|
||||
err := e.EncodeElement(value, xml.StartElement{
|
||||
Name: xml.Name{Local: key},
|
||||
Attr: attrs,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("%sfailed to encode %q: %w", errPrefix[0], key, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,11 @@
|
||||
module code.capytal.cc/capytal/comicverse/editor
|
||||
|
||||
go 1.25.2
|
||||
|
||||
require (
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1
|
||||
github.com/google/uuid v1.6.0
|
||||
golang.org/x/net v0.47.0
|
||||
golang.org/x/text v0.31.0
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e h1:LdkirHDzhkcnhOBnDN0po84DjHAAkGztjHu/4mfWpSI=
|
||||
code.capytal.cc/loreddev/smalltrip v0.0.0-20251113171745-e3813daa807e/go.mod h1:jMvSPUj295pTk/ixyxZfwZJE/RQ7DZzvQ3cVoAklkPA=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1 h1:BE0QdvwVVTG/t7nwNO5rrLf1vdAc5axv/1mWd/oAWhw=
|
||||
code.capytal.cc/loreddev/x v0.0.0-20251113171626-2ce5d71249c1/go.mod h1:p5ZPHzutdbUDfpvNBCjv5ls6rM4YNl2k4ipD5b0aRho=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
|
||||
8
editor/internals/randname/COPYRIGHT
Normal file
8
editor/internals/randname/COPYRIGHT
Normal file
@@ -0,0 +1,8 @@
|
||||
Adjectives and names list files were copied from Dustin Kirkland's <dustin.kirkland@gmail.com>
|
||||
petname project at Github, specifically from these files:
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/adjectives.txt
|
||||
- https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/usr/share/petname/small/names.txt
|
||||
|
||||
The original files are provided and released under the Apache License version 2,
|
||||
which a copy is available at
|
||||
https://github.com/dustinkirkland/petname/blob/5a79a2954e94ddbe6dc47b35c8b08ed931fc0e7f/LICENSE
|
||||
449
editor/internals/randname/adjectives.txt
Normal file
449
editor/internals/randname/adjectives.txt
Normal file
@@ -0,0 +1,449 @@
|
||||
able
|
||||
above
|
||||
absolute
|
||||
accepted
|
||||
accurate
|
||||
ace
|
||||
active
|
||||
actual
|
||||
adapted
|
||||
adapting
|
||||
adequate
|
||||
adjusted
|
||||
advanced
|
||||
alert
|
||||
alive
|
||||
allowed
|
||||
allowing
|
||||
amazed
|
||||
amazing
|
||||
ample
|
||||
amused
|
||||
amusing
|
||||
apparent
|
||||
apt
|
||||
arriving
|
||||
artistic
|
||||
assured
|
||||
assuring
|
||||
awaited
|
||||
awake
|
||||
aware
|
||||
balanced
|
||||
becoming
|
||||
beloved
|
||||
better
|
||||
big
|
||||
blessed
|
||||
bold
|
||||
boss
|
||||
brave
|
||||
brief
|
||||
bright
|
||||
bursting
|
||||
busy
|
||||
calm
|
||||
capable
|
||||
capital
|
||||
careful
|
||||
caring
|
||||
casual
|
||||
causal
|
||||
central
|
||||
certain
|
||||
champion
|
||||
charmed
|
||||
charming
|
||||
cheerful
|
||||
chief
|
||||
choice
|
||||
civil
|
||||
classic
|
||||
clean
|
||||
clear
|
||||
clever
|
||||
climbing
|
||||
close
|
||||
closing
|
||||
coherent
|
||||
comic
|
||||
communal
|
||||
complete
|
||||
composed
|
||||
concise
|
||||
concrete
|
||||
content
|
||||
cool
|
||||
correct
|
||||
cosmic
|
||||
crack
|
||||
creative
|
||||
credible
|
||||
crisp
|
||||
crucial
|
||||
cuddly
|
||||
cunning
|
||||
curious
|
||||
current
|
||||
cute
|
||||
daring
|
||||
darling
|
||||
dashing
|
||||
dear
|
||||
decent
|
||||
deciding
|
||||
deep
|
||||
definite
|
||||
delicate
|
||||
desired
|
||||
destined
|
||||
devoted
|
||||
direct
|
||||
discrete
|
||||
distinct
|
||||
diverse
|
||||
divine
|
||||
dominant
|
||||
driven
|
||||
driving
|
||||
dynamic
|
||||
eager
|
||||
easy
|
||||
electric
|
||||
elegant
|
||||
emerging
|
||||
eminent
|
||||
enabled
|
||||
enabling
|
||||
endless
|
||||
engaged
|
||||
engaging
|
||||
enhanced
|
||||
enjoyed
|
||||
enormous
|
||||
enough
|
||||
epic
|
||||
equal
|
||||
equipped
|
||||
eternal
|
||||
ethical
|
||||
evident
|
||||
evolved
|
||||
evolving
|
||||
exact
|
||||
excited
|
||||
exciting
|
||||
exotic
|
||||
expert
|
||||
factual
|
||||
fair
|
||||
faithful
|
||||
famous
|
||||
fancy
|
||||
fast
|
||||
feasible
|
||||
fine
|
||||
finer
|
||||
firm
|
||||
first
|
||||
fit
|
||||
fitting
|
||||
fleet
|
||||
flexible
|
||||
flowing
|
||||
fluent
|
||||
flying
|
||||
fond
|
||||
frank
|
||||
free
|
||||
fresh
|
||||
full
|
||||
fun
|
||||
funky
|
||||
funny
|
||||
game
|
||||
generous
|
||||
gentle
|
||||
genuine
|
||||
giving
|
||||
glad
|
||||
glorious
|
||||
glowing
|
||||
golden
|
||||
good
|
||||
gorgeous
|
||||
grand
|
||||
grateful
|
||||
great
|
||||
growing
|
||||
grown
|
||||
guided
|
||||
guiding
|
||||
handy
|
||||
happy
|
||||
hardy
|
||||
harmless
|
||||
healthy
|
||||
helped
|
||||
helpful
|
||||
helping
|
||||
heroic
|
||||
hip
|
||||
holy
|
||||
honest
|
||||
hopeful
|
||||
hot
|
||||
huge
|
||||
humane
|
||||
humble
|
||||
humorous
|
||||
ideal
|
||||
immense
|
||||
immortal
|
||||
immune
|
||||
improved
|
||||
in
|
||||
included
|
||||
infinite
|
||||
informed
|
||||
innocent
|
||||
inspired
|
||||
integral
|
||||
intense
|
||||
intent
|
||||
internal
|
||||
intimate
|
||||
inviting
|
||||
joint
|
||||
just
|
||||
keen
|
||||
key
|
||||
kind
|
||||
knowing
|
||||
known
|
||||
large
|
||||
lasting
|
||||
leading
|
||||
learning
|
||||
legal
|
||||
legible
|
||||
lenient
|
||||
liberal
|
||||
light
|
||||
liked
|
||||
literate
|
||||
live
|
||||
living
|
||||
logical
|
||||
loved
|
||||
loving
|
||||
loyal
|
||||
lucky
|
||||
magical
|
||||
magnetic
|
||||
main
|
||||
major
|
||||
many
|
||||
massive
|
||||
master
|
||||
mature
|
||||
maximum
|
||||
measured
|
||||
meet
|
||||
merry
|
||||
mighty
|
||||
mint
|
||||
model
|
||||
modern
|
||||
modest
|
||||
moral
|
||||
more
|
||||
moved
|
||||
moving
|
||||
musical
|
||||
mutual
|
||||
national
|
||||
native
|
||||
natural
|
||||
nearby
|
||||
neat
|
||||
needed
|
||||
neutral
|
||||
new
|
||||
next
|
||||
nice
|
||||
noble
|
||||
normal
|
||||
notable
|
||||
noted
|
||||
novel
|
||||
obliging
|
||||
on
|
||||
one
|
||||
open
|
||||
optimal
|
||||
optimum
|
||||
organic
|
||||
oriented
|
||||
outgoing
|
||||
patient
|
||||
peaceful
|
||||
perfect
|
||||
pet
|
||||
picked
|
||||
pleasant
|
||||
pleased
|
||||
pleasing
|
||||
poetic
|
||||
polished
|
||||
polite
|
||||
popular
|
||||
positive
|
||||
possible
|
||||
powerful
|
||||
precious
|
||||
precise
|
||||
premium
|
||||
prepared
|
||||
present
|
||||
pretty
|
||||
primary
|
||||
prime
|
||||
pro
|
||||
probable
|
||||
profound
|
||||
promoted
|
||||
prompt
|
||||
proper
|
||||
proud
|
||||
proven
|
||||
pumped
|
||||
pure
|
||||
quality
|
||||
quick
|
||||
quiet
|
||||
rapid
|
||||
rare
|
||||
rational
|
||||
ready
|
||||
real
|
||||
refined
|
||||
regular
|
||||
related
|
||||
relative
|
||||
relaxed
|
||||
relaxing
|
||||
relevant
|
||||
relieved
|
||||
renewed
|
||||
renewing
|
||||
resolved
|
||||
rested
|
||||
rich
|
||||
right
|
||||
robust
|
||||
romantic
|
||||
ruling
|
||||
sacred
|
||||
safe
|
||||
saved
|
||||
saving
|
||||
secure
|
||||
select
|
||||
selected
|
||||
sensible
|
||||
set
|
||||
settled
|
||||
settling
|
||||
sharing
|
||||
sharp
|
||||
shining
|
||||
simple
|
||||
sincere
|
||||
singular
|
||||
skilled
|
||||
smart
|
||||
smashing
|
||||
smiling
|
||||
smooth
|
||||
social
|
||||
solid
|
||||
sought
|
||||
sound
|
||||
special
|
||||
splendid
|
||||
square
|
||||
stable
|
||||
star
|
||||
steady
|
||||
sterling
|
||||
still
|
||||
stirred
|
||||
stirring
|
||||
striking
|
||||
strong
|
||||
stunning
|
||||
subtle
|
||||
suitable
|
||||
suited
|
||||
summary
|
||||
sunny
|
||||
super
|
||||
superb
|
||||
supreme
|
||||
sure
|
||||
sweeping
|
||||
sweet
|
||||
talented
|
||||
teaching
|
||||
tender
|
||||
thankful
|
||||
thorough
|
||||
tidy
|
||||
tight
|
||||
together
|
||||
tolerant
|
||||
top
|
||||
topical
|
||||
tops
|
||||
touched
|
||||
touching
|
||||
tough
|
||||
true
|
||||
trusted
|
||||
trusting
|
||||
trusty
|
||||
ultimate
|
||||
unbiased
|
||||
uncommon
|
||||
unified
|
||||
unique
|
||||
united
|
||||
up
|
||||
upright
|
||||
upward
|
||||
usable
|
||||
useful
|
||||
valid
|
||||
valued
|
||||
vast
|
||||
verified
|
||||
viable
|
||||
vital
|
||||
vocal
|
||||
wanted
|
||||
warm
|
||||
wealthy
|
||||
welcome
|
||||
welcomed
|
||||
well
|
||||
whole
|
||||
willing
|
||||
winning
|
||||
wired
|
||||
wise
|
||||
witty
|
||||
wondrous
|
||||
workable
|
||||
working
|
||||
worthy
|
||||
452
editor/internals/randname/names.txt
Normal file
452
editor/internals/randname/names.txt
Normal file
@@ -0,0 +1,452 @@
|
||||
ox
|
||||
ant
|
||||
ape
|
||||
asp
|
||||
bat
|
||||
bee
|
||||
boa
|
||||
bug
|
||||
cat
|
||||
cod
|
||||
cow
|
||||
cub
|
||||
doe
|
||||
dog
|
||||
eel
|
||||
eft
|
||||
elf
|
||||
elk
|
||||
emu
|
||||
ewe
|
||||
fly
|
||||
fox
|
||||
gar
|
||||
gnu
|
||||
hen
|
||||
hog
|
||||
imp
|
||||
jay
|
||||
kid
|
||||
kit
|
||||
koi
|
||||
lab
|
||||
man
|
||||
owl
|
||||
pig
|
||||
pug
|
||||
pup
|
||||
ram
|
||||
rat
|
||||
ray
|
||||
yak
|
||||
bass
|
||||
bear
|
||||
bird
|
||||
boar
|
||||
buck
|
||||
bull
|
||||
calf
|
||||
chow
|
||||
clam
|
||||
colt
|
||||
crab
|
||||
crow
|
||||
dane
|
||||
deer
|
||||
dodo
|
||||
dory
|
||||
dove
|
||||
drum
|
||||
duck
|
||||
fawn
|
||||
fish
|
||||
flea
|
||||
foal
|
||||
fowl
|
||||
frog
|
||||
gnat
|
||||
goat
|
||||
grub
|
||||
gull
|
||||
hare
|
||||
hawk
|
||||
ibex
|
||||
joey
|
||||
kite
|
||||
kiwi
|
||||
lamb
|
||||
lark
|
||||
lion
|
||||
loon
|
||||
lynx
|
||||
mako
|
||||
mink
|
||||
mite
|
||||
mole
|
||||
moth
|
||||
mule
|
||||
mutt
|
||||
newt
|
||||
orca
|
||||
oryx
|
||||
pika
|
||||
pony
|
||||
puma
|
||||
seal
|
||||
shad
|
||||
slug
|
||||
sole
|
||||
stag
|
||||
stud
|
||||
swan
|
||||
tahr
|
||||
teal
|
||||
tick
|
||||
toad
|
||||
tuna
|
||||
wasp
|
||||
wolf
|
||||
worm
|
||||
wren
|
||||
yeti
|
||||
adder
|
||||
akita
|
||||
alien
|
||||
aphid
|
||||
bison
|
||||
boxer
|
||||
bream
|
||||
bunny
|
||||
burro
|
||||
camel
|
||||
chimp
|
||||
civet
|
||||
cobra
|
||||
coral
|
||||
corgi
|
||||
crane
|
||||
dingo
|
||||
drake
|
||||
eagle
|
||||
egret
|
||||
filly
|
||||
finch
|
||||
gator
|
||||
gecko
|
||||
ghost
|
||||
ghoul
|
||||
goose
|
||||
guppy
|
||||
heron
|
||||
hippo
|
||||
horse
|
||||
hound
|
||||
husky
|
||||
hyena
|
||||
koala
|
||||
krill
|
||||
leech
|
||||
lemur
|
||||
liger
|
||||
llama
|
||||
louse
|
||||
macaw
|
||||
midge
|
||||
molly
|
||||
moose
|
||||
moray
|
||||
mouse
|
||||
panda
|
||||
perch
|
||||
prawn
|
||||
quail
|
||||
racer
|
||||
raven
|
||||
rhino
|
||||
robin
|
||||
satyr
|
||||
shark
|
||||
sheep
|
||||
shrew
|
||||
skink
|
||||
skunk
|
||||
sloth
|
||||
snail
|
||||
snake
|
||||
snipe
|
||||
squid
|
||||
stork
|
||||
swift
|
||||
tapir
|
||||
tetra
|
||||
tiger
|
||||
troll
|
||||
trout
|
||||
viper
|
||||
wahoo
|
||||
whale
|
||||
zebra
|
||||
alpaca
|
||||
amoeba
|
||||
baboon
|
||||
badger
|
||||
beagle
|
||||
bedbug
|
||||
beetle
|
||||
bengal
|
||||
bobcat
|
||||
caiman
|
||||
cattle
|
||||
cicada
|
||||
collie
|
||||
condor
|
||||
cougar
|
||||
coyote
|
||||
dassie
|
||||
dragon
|
||||
earwig
|
||||
falcon
|
||||
feline
|
||||
ferret
|
||||
gannet
|
||||
gibbon
|
||||
glider
|
||||
goblin
|
||||
gopher
|
||||
grouse
|
||||
guinea
|
||||
hermit
|
||||
hornet
|
||||
iguana
|
||||
impala
|
||||
insect
|
||||
jackal
|
||||
jaguar
|
||||
jennet
|
||||
kitten
|
||||
kodiak
|
||||
lizard
|
||||
locust
|
||||
maggot
|
||||
magpie
|
||||
mammal
|
||||
mantis
|
||||
marlin
|
||||
marmot
|
||||
marten
|
||||
martin
|
||||
mayfly
|
||||
minnow
|
||||
monkey
|
||||
mullet
|
||||
muskox
|
||||
ocelot
|
||||
oriole
|
||||
osprey
|
||||
oyster
|
||||
parrot
|
||||
pigeon
|
||||
piglet
|
||||
poodle
|
||||
possum
|
||||
python
|
||||
quagga
|
||||
rabbit
|
||||
raptor
|
||||
rodent
|
||||
roughy
|
||||
salmon
|
||||
sawfly
|
||||
serval
|
||||
shiner
|
||||
shrimp
|
||||
spider
|
||||
sponge
|
||||
tarpon
|
||||
thrush
|
||||
tomcat
|
||||
toucan
|
||||
turkey
|
||||
turtle
|
||||
urchin
|
||||
vervet
|
||||
walrus
|
||||
weasel
|
||||
weevil
|
||||
wombat
|
||||
anchovy
|
||||
anemone
|
||||
bluejay
|
||||
buffalo
|
||||
bulldog
|
||||
buzzard
|
||||
caribou
|
||||
catfish
|
||||
chamois
|
||||
cheetah
|
||||
chicken
|
||||
chigger
|
||||
cowbird
|
||||
crappie
|
||||
crawdad
|
||||
cricket
|
||||
dogfish
|
||||
dolphin
|
||||
firefly
|
||||
garfish
|
||||
gazelle
|
||||
gelding
|
||||
giraffe
|
||||
gobbler
|
||||
gorilla
|
||||
goshawk
|
||||
grackle
|
||||
griffon
|
||||
grizzly
|
||||
grouper
|
||||
haddock
|
||||
hagfish
|
||||
halibut
|
||||
hamster
|
||||
herring
|
||||
javelin
|
||||
jawfish
|
||||
jaybird
|
||||
katydid
|
||||
ladybug
|
||||
lamprey
|
||||
lemming
|
||||
leopard
|
||||
lioness
|
||||
lobster
|
||||
macaque
|
||||
mallard
|
||||
mammoth
|
||||
manatee
|
||||
mastiff
|
||||
meerkat
|
||||
mollusk
|
||||
monarch
|
||||
mongrel
|
||||
monitor
|
||||
monster
|
||||
mudfish
|
||||
muskrat
|
||||
mustang
|
||||
narwhal
|
||||
oarfish
|
||||
octopus
|
||||
opossum
|
||||
ostrich
|
||||
panther
|
||||
peacock
|
||||
pegasus
|
||||
pelican
|
||||
penguin
|
||||
phoenix
|
||||
piranha
|
||||
polecat
|
||||
primate
|
||||
quetzal
|
||||
raccoon
|
||||
rattler
|
||||
redbird
|
||||
redfish
|
||||
reptile
|
||||
rooster
|
||||
sawfish
|
||||
sculpin
|
||||
seagull
|
||||
skylark
|
||||
snapper
|
||||
spaniel
|
||||
sparrow
|
||||
sunbeam
|
||||
sunbird
|
||||
sunfish
|
||||
tadpole
|
||||
terrier
|
||||
unicorn
|
||||
vulture
|
||||
wallaby
|
||||
walleye
|
||||
warthog
|
||||
whippet
|
||||
wildcat
|
||||
aardvark
|
||||
airedale
|
||||
albacore
|
||||
anteater
|
||||
antelope
|
||||
arachnid
|
||||
barnacle
|
||||
basilisk
|
||||
blowfish
|
||||
bluebird
|
||||
bluegill
|
||||
bonefish
|
||||
bullfrog
|
||||
cardinal
|
||||
chipmunk
|
||||
cockatoo
|
||||
crayfish
|
||||
dinosaur
|
||||
doberman
|
||||
duckling
|
||||
elephant
|
||||
escargot
|
||||
flamingo
|
||||
flounder
|
||||
foxhound
|
||||
glowworm
|
||||
goldfish
|
||||
grubworm
|
||||
hedgehog
|
||||
honeybee
|
||||
hookworm
|
||||
humpback
|
||||
kangaroo
|
||||
killdeer
|
||||
kingfish
|
||||
labrador
|
||||
lacewing
|
||||
ladybird
|
||||
lionfish
|
||||
longhorn
|
||||
mackerel
|
||||
malamute
|
||||
marmoset
|
||||
mastodon
|
||||
moccasin
|
||||
mongoose
|
||||
monkfish
|
||||
mosquito
|
||||
pangolin
|
||||
parakeet
|
||||
pheasant
|
||||
pipefish
|
||||
platypus
|
||||
polliwog
|
||||
porpoise
|
||||
reindeer
|
||||
ringtail
|
||||
sailfish
|
||||
scorpion
|
||||
seahorse
|
||||
seasnail
|
||||
sheepdog
|
||||
shepherd
|
||||
silkworm
|
||||
squirrel
|
||||
stallion
|
||||
starfish
|
||||
starling
|
||||
stingray
|
||||
stinkbug
|
||||
sturgeon
|
||||
terrapin
|
||||
titmouse
|
||||
tortoise
|
||||
treefrog
|
||||
werewolf
|
||||
woodcock
|
||||
33
editor/internals/randname/randname.go
Normal file
33
editor/internals/randname/randname.go
Normal file
@@ -0,0 +1,33 @@
|
||||
package randname
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TODO: Make generator be based on fantasy, sci-fi and other literature
|
||||
// and artistic names.
|
||||
|
||||
//go:embed adjectives.txt
|
||||
var adjectives string
|
||||
|
||||
//go:embed names.txt
|
||||
var names string
|
||||
|
||||
var (
|
||||
adjectivesList = strings.Split(adjectives, "\n")
|
||||
namesList = strings.Split(names, "\n")
|
||||
)
|
||||
|
||||
func New(sep ...string) string {
|
||||
if len(sep) == 0 {
|
||||
sep = append(sep, " ")
|
||||
}
|
||||
|
||||
a := adjectivesList[rand.Intn(len(adjectivesList))]
|
||||
n := namesList[rand.Intn(len(namesList))]
|
||||
|
||||
return fmt.Sprintf("%s%s%s", a, sep[0], n)
|
||||
}
|
||||
27
editor/router/dashboard.go
Normal file
27
editor/router/dashboard.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
type dashboardController struct {
|
||||
editor *editor.Editor
|
||||
templater xtemplate.Templater
|
||||
}
|
||||
|
||||
func (ctrl *dashboardController) dashboard(w http.ResponseWriter, r *http.Request) {
|
||||
randtitle := randname.New()
|
||||
|
||||
err := ctrl.templater.ExecuteTemplate(w, "editor-dashboard", map[string]any{
|
||||
"RandTitle": randtitle,
|
||||
})
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
}
|
||||
68
editor/router/publication.go
Normal file
68
editor/router/publication.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/capytal/comicverse/editor/internals/randname"
|
||||
"code.capytal.cc/loreddev/smalltrip/problem"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/text/language"
|
||||
)
|
||||
|
||||
type publicationController struct {
|
||||
editor *editor.Editor
|
||||
templater xtemplate.Templater
|
||||
}
|
||||
|
||||
func (ctrl *publicationController) createPublication(w http.ResponseWriter, r *http.Request) {
|
||||
title := r.FormValue("title")
|
||||
if title == "" {
|
||||
title = randname.New()
|
||||
}
|
||||
|
||||
lang := language.English
|
||||
|
||||
id, err := uuid.NewV7()
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = ctrl.editor.New(id, title, lang)
|
||||
if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
http.Redirect(w, r, fmt.Sprintf("./%s", id), http.StatusTemporaryRedirect)
|
||||
}
|
||||
|
||||
func (ctrl *publicationController) getPublication(w http.ResponseWriter, r *http.Request) {
|
||||
idstr := r.PathValue("publicationID")
|
||||
if idstr == "" {
|
||||
problem.NewBadRequest("Missing publication ID in path").ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := uuid.Parse(idstr)
|
||||
if err != nil {
|
||||
problem.NewBadRequest("Invalid UUID in path", problem.WithError(err)).ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
pkg, err := ctrl.editor.Open(id)
|
||||
if errors.Is(err, editor.ErrNotExists) {
|
||||
problem.NewNotFound().ServeHTTP(w, r)
|
||||
return
|
||||
} else if err != nil {
|
||||
problem.NewInternalServerError(err).ServeHTTP(w, r)
|
||||
return
|
||||
|
||||
}
|
||||
|
||||
w.Write(fmt.Appendf([]byte{}, "%+v", pkg))
|
||||
}
|
||||
52
editor/router/router.go
Normal file
52
editor/router/router.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
"code.capytal.cc/capytal/comicverse/editor"
|
||||
"code.capytal.cc/loreddev/smalltrip"
|
||||
"code.capytal.cc/loreddev/smalltrip/middleware"
|
||||
"code.capytal.cc/loreddev/smalltrip/multiplexer"
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
func New(cfg Config) http.Handler {
|
||||
log := cfg.Logger
|
||||
|
||||
mux := multiplexer.New()
|
||||
mux = multiplexer.WithFormMethod(mux, "x-method")
|
||||
mux = multiplexer.WithPatternRules(mux,
|
||||
multiplexer.EnsureMethod(),
|
||||
multiplexer.EnsureTrailingSlash(),
|
||||
multiplexer.EnsureStrictEnd(),
|
||||
)
|
||||
|
||||
r := smalltrip.NewRouter(
|
||||
smalltrip.WithMultiplexer(mux),
|
||||
smalltrip.WithLogger(log.WithGroup("router")),
|
||||
)
|
||||
|
||||
r.Use(middleware.Logger(log.WithGroup("requests")))
|
||||
// r.Use(problem.Middleware(problem.DefaultHandler))
|
||||
|
||||
r.Handle("GET /assets/{asset...}", http.StripPrefix("/assets/", http.FileServerFS(cfg.Assets)))
|
||||
|
||||
dashboardCtrl := &dashboardController{editor: cfg.Editor, templater: cfg.Templater}
|
||||
publicationCtrl := &publicationController{editor: cfg.Editor, templater: cfg.Templater}
|
||||
|
||||
r.HandleFunc("GET /{$}", dashboardCtrl.dashboard)
|
||||
|
||||
r.HandleFunc("POST /publication/{$}", publicationCtrl.createPublication)
|
||||
r.HandleFunc("GET /publication/{publicationID}/{$}", publicationCtrl.getPublication)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Assets fs.FS
|
||||
Editor *editor.Editor
|
||||
Templater xtemplate.Templater
|
||||
Logger *slog.Logger
|
||||
}
|
||||
98
editor/storage/local.go
Normal file
98
editor/storage/local.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
func Newlocal(
|
||||
root *os.Root,
|
||||
logger *slog.Logger,
|
||||
) Storage {
|
||||
return &local{
|
||||
log: logger,
|
||||
root: root,
|
||||
}
|
||||
}
|
||||
|
||||
type local struct {
|
||||
log *slog.Logger
|
||||
root *os.Root
|
||||
}
|
||||
|
||||
var _ Storage = (*local)(nil)
|
||||
|
||||
func (files *local) Exists(p string) bool {
|
||||
if _, err := files.root.Stat(p); err != nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (files *local) Open(p string) (fs.File, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Opening file")
|
||||
defer log.Debug("File opened")
|
||||
|
||||
f, err := files.root.Open(p)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, ErrNotExists
|
||||
} else if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (files *local) Write(p string, d []byte) (int, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Writing file")
|
||||
defer log.Debug("File wrote")
|
||||
|
||||
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
|
||||
}
|
||||
|
||||
err := files.root.WriteFile(p, d, os.ModePerm)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
|
||||
}
|
||||
|
||||
return len(d), nil
|
||||
}
|
||||
|
||||
func (files *local) WriteFrom(p string, r io.Reader) (int64, error) {
|
||||
log := files.log.With(
|
||||
slog.String("path", p),
|
||||
slog.String("root", files.root.Name()))
|
||||
|
||||
log.Debug("Writing file")
|
||||
defer log.Debug("File wrote")
|
||||
|
||||
if err := files.root.MkdirAll(path.Dir(p), os.ModePerm); err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create parent directories %q: %w", path.Dir(p), err)
|
||||
}
|
||||
|
||||
f, err := files.root.Create(p)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to create file %q: %w", p, err)
|
||||
}
|
||||
|
||||
n, err := f.ReadFrom(r)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("file.local: failed to write file %q: %w", p, err)
|
||||
}
|
||||
|
||||
return n, nil
|
||||
}
|
||||
42
editor/storage/storage.go
Normal file
42
editor/storage/storage.go
Normal file
@@ -0,0 +1,42 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
)
|
||||
|
||||
type Storage interface {
|
||||
Exists(p string) bool
|
||||
Open(p string) (fs.File, error)
|
||||
Write(p string, b []byte) (int, error)
|
||||
WriteFrom(p string, r io.Reader) (int64, error)
|
||||
}
|
||||
|
||||
type withRoot struct {
|
||||
root string
|
||||
Storage
|
||||
}
|
||||
|
||||
func WithRoot(rootDir string, s Storage) Storage {
|
||||
return &withRoot{root: rootDir, Storage: s}
|
||||
}
|
||||
|
||||
func (f *withRoot) Exists(p string) bool {
|
||||
return f.Storage.Exists(path.Join(f.root, p))
|
||||
}
|
||||
|
||||
func (f *withRoot) Open(p string) (fs.File, error) {
|
||||
return f.Storage.Open(path.Join(f.root, p))
|
||||
}
|
||||
|
||||
func (f *withRoot) Write(p string, b []byte) (int, error) {
|
||||
return f.Storage.Write(path.Join(f.root, p), b)
|
||||
}
|
||||
|
||||
func (f *withRoot) WriteFrom(p string, r io.Reader) (int64, error) {
|
||||
return f.Storage.WriteFrom(path.Join(f.root, p), r)
|
||||
}
|
||||
|
||||
var ErrNotExists = os.ErrNotExist
|
||||
31
editor/template/dashboard.html
Normal file
31
editor/template/dashboard.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{{define "editor-dashboard"}} {{template "layout-base"}}
|
||||
<body class="bg-gray-900 text-gray-50 has-[#first-publication]:h-svw">
|
||||
<main class="has-[#first-publication]:h-full flex flex-col">
|
||||
{{if .Publications}}
|
||||
<p>Publications</p>
|
||||
{{else}}
|
||||
<h1>Create your first publication</h1>
|
||||
<form method="post" action="/publication/" id="first-publication">
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
name="title"
|
||||
value="{{if .RandTitle}}{{.RandTitle}}{{end}}"
|
||||
/><button type="submit">Create</button>
|
||||
</form>
|
||||
<style>
|
||||
body:has(:is(#first-publication)) {
|
||||
height: 100svh;
|
||||
& > main {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{{end}}
|
||||
</main>
|
||||
</body>
|
||||
{{template "layout-base-end"}} {{end}}
|
||||
19
editor/template/layouts/layout-base.html
Normal file
19
editor/template/layouts/layout-base.html
Normal file
@@ -0,0 +1,19 @@
|
||||
{{define "layout-base"}}
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
{{if .Title}}
|
||||
<title>{{.Title}}</title>
|
||||
{{end}}
|
||||
<link href="/assets/css/style.css" rel="stylesheet" />
|
||||
<script
|
||||
src="https://unpkg.com/htmx.org@2.0.4/dist/htmx.js"
|
||||
integrity="sha384-oeUn82QNXPuVkGCkcrInrS1twIxKhkZiFfr2TdiuObZ3n3yIeMiqcRzkIcguaof1"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
</head>
|
||||
{{end}} {{define "layout-base-end"}}
|
||||
</html>
|
||||
{{end}}
|
||||
50
editor/template/template.go
Normal file
50
editor/template/template.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package template
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/fs"
|
||||
|
||||
"code.capytal.cc/loreddev/x/xtemplate"
|
||||
)
|
||||
|
||||
func New() (xtemplate.Template, error) {
|
||||
return xtemplate.New[template.Template]("template").
|
||||
Funcs(functions).
|
||||
ParseFS(embedded, patterns...)
|
||||
}
|
||||
|
||||
//go:embed *.html layouts/*.html
|
||||
var embedded embed.FS
|
||||
|
||||
func Dev(dir fs.FS) (xtemplate.Template, error) {
|
||||
return xtemplate.NewHot[template.Template]("template").
|
||||
Funcs(functions).
|
||||
ParseFS(dir, patterns...)
|
||||
}
|
||||
|
||||
var (
|
||||
patterns = []string{"*.html", "layouts/*.html"}
|
||||
functions = template.FuncMap{
|
||||
"args": func(pairs ...any) (map[string]any, error) {
|
||||
if len(pairs)%2 != 0 {
|
||||
return nil, errors.New("misaligned map in template arguments")
|
||||
}
|
||||
|
||||
m := make(map[string]any, len(pairs)/2)
|
||||
|
||||
for i := 0; i < len(pairs); i += 2 {
|
||||
key, ok := pairs[i].(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("cannot use type %T as map key", pairs[i])
|
||||
}
|
||||
|
||||
m[key] = pairs[i+1]
|
||||
}
|
||||
|
||||
return m, nil
|
||||
},
|
||||
}
|
||||
)
|
||||
20
makefile
20
makefile
@@ -30,6 +30,26 @@ dev:
|
||||
dev/debug:
|
||||
$(MAKE) -j2 debug dev/assets
|
||||
|
||||
editor/dev/server:
|
||||
cd ./editor; go run github.com/joho/godotenv/cmd/godotenv@v1.5.1 \
|
||||
go run github.com/air-verse/air@v1.52.2 \
|
||||
--build.cmd "go build -o tmp/bin/main ./cmd" \
|
||||
--build.bin "tmp/bin/main" \
|
||||
--build.exclude_dir "node_modules" \
|
||||
--build.include_ext "go" \
|
||||
--build.stop_on_error "false" \
|
||||
--misc.clean_on_exit true \
|
||||
-- -dev -port $(PORT) -hostname 0.0.0.0
|
||||
|
||||
editor/dev/assets:
|
||||
cd ./editor; tailwindcss \
|
||||
-i ./assets/css/tailwind.css \
|
||||
-o ./assets/css/style.css \
|
||||
--watch
|
||||
|
||||
editor/dev:
|
||||
$(MAKE) -j2 editor/dev/assets editor/dev/server
|
||||
|
||||
debug:
|
||||
dlv debug -l 127.0.0.1:38697 \
|
||||
--continue \
|
||||
|
||||
Reference in New Issue
Block a user