mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 06:28:34 +01:00
Compare commits
32 Commits
copilot/fi
...
mk/daily-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e47a7ff6f2 | ||
|
|
57385f9ada | ||
|
|
94df40b718 | ||
|
|
c9cb1abdd9 | ||
|
|
76d7b3d6ca | ||
|
|
017b05452a | ||
|
|
6a1f2549df | ||
|
|
2f51ba419b | ||
|
|
bcd77ee796 | ||
|
|
23cecfb2a5 | ||
|
|
f4c8dd5450 | ||
|
|
72033341b4 | ||
|
|
e5ba3bb10e | ||
|
|
c2cfa10336 | ||
|
|
a73d5887c7 | ||
|
|
850ed476d4 | ||
|
|
adea83d421 | ||
|
|
d678c35c57 | ||
|
|
d3073b472d | ||
|
|
b274f81dbb | ||
|
|
721e407600 | ||
|
|
ca4aed7bd3 | ||
|
|
746bd50f24 | ||
|
|
945a768691 | ||
|
|
27237ae6d3 | ||
|
|
4463867cf0 | ||
|
|
95e2fb6a15 | ||
|
|
e32dd0aaa5 | ||
|
|
ee5c43dc29 | ||
|
|
17c9685391 | ||
|
|
fd41065250 | ||
|
|
f9b1327507 |
@@ -29,6 +29,6 @@
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": "1.21"
|
||||
"ghcr.io/devcontainers/features/go:1": "1.24"
|
||||
}
|
||||
}
|
||||
|
||||
40
.github/AGENTS.md
vendored
Normal file
40
.github/AGENTS.md
vendored
Normal file
@@ -0,0 +1,40 @@
|
||||
This is a Go based repository with a VueJS client for the frontend built with Vite and Nuxt, with ShadCN.
|
||||
|
||||
To make life easier, the use of a Taskfile is included for the majority of development commands.
|
||||
|
||||
Please follow these guidelines when contributing:
|
||||
|
||||
## Required Before Each Commit
|
||||
- Generate Swagger Files: `task swag --force`
|
||||
- Generate JS API Client: `task typescript-types --force`
|
||||
- Lint Golang: `task go:lint`
|
||||
- Lint frontend: `task ui:fix`
|
||||
|
||||
## Repository Structure
|
||||
### Backend
|
||||
- `backend/`: Contains the backend folders
|
||||
- `backend/app`: Contains main app code including API endpoints
|
||||
- `backend/internal/core`: Contains basic services such as currencies
|
||||
- `backend/data`: Contains all information related to data, including `ent` schemas, repos, migrations, etc.
|
||||
- `backend/data/migrations`: Contains migration data, the `sqlite3` sub-folder contains sqlite migrations, `postgres` sub-folder the postgres migrations, BOTH are REQUIRED.
|
||||
- `backend/data/ent/schema`: Contains the actual `ent` data models.
|
||||
- `backend/data/repo`: Contains the data repositories
|
||||
- `backend/pkgs`: Contains general helper functions and services
|
||||
|
||||
### Frontend
|
||||
- `frontend/`: Contains initial frontend files
|
||||
- `frontend/components`: Contains the ShadCN components
|
||||
- `frontend/locales`: Contains the i18n JSON for languages
|
||||
- `frontend/pages`: Contains VueJS pages
|
||||
- `frontend/test`: Contains Playwright setup
|
||||
- `frontend/test/e2e`: Contains actual Playwright test files
|
||||
|
||||
### Docs
|
||||
- `docs/`: Contains VitePress based documentation
|
||||
|
||||
## Key Guidelines
|
||||
1. Follow best practices for the various programming languages
|
||||
2. Maintain existing code structure and organization when possible
|
||||
3. Use dependency injection when reasonable
|
||||
4. Write tests for new functionality and after fixing bugs to validate they're fixed
|
||||
5. Document changes to the `docs/` folder when appropriate
|
||||
52
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
52
.github/workflows/copilot-setup-steps.yml
vendored
Normal file
@@ -0,0 +1,52 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||
# allow manual testing through the repository's "Actions" tab
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Set the permissions to the lowest permissions possible needed for your steps.
|
||||
# Copilot will be given its own token for its operations.
|
||||
permissions:
|
||||
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
||||
contents: read
|
||||
|
||||
# You can define any steps you want, and they will run before the agent starts.
|
||||
# If you do not check out your code, Copilot will do this for you.
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Perform setup
|
||||
run: task setup
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) && secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) && secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
- name: Create manifest list and push Dockerhub
|
||||
id: push-dockerhub
|
||||
working-directory: /tmp/digests
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) && secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
|
||||
6
.github/workflows/docker-publish.yaml
vendored
6
.github/workflows/docker-publish.yaml
vendored
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) && secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -152,7 +152,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -195,7 +195,7 @@ jobs:
|
||||
- name: Create manifest list and push Dockerhub
|
||||
id: push-dockerhub
|
||||
working-directory: /tmp/digests
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')) && secrets.DOCKER_USERNAME != ''
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
|
||||
@@ -14,6 +14,7 @@ builds:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- "386"
|
||||
@@ -25,11 +26,16 @@ builds:
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: "386"
|
||||
tags:
|
||||
- >-
|
||||
{{- if eq .Arch "riscv64" }}nodynamic
|
||||
{{- else if eq .Arch "arm" }}nodynamic
|
||||
{{- else if eq .Arch "386" }}nodynamic
|
||||
{{- else if eq .Os "freebsd" }}nodynamic
|
||||
{{ end }}
|
||||
|
||||
signs:
|
||||
|
||||
@@ -1,21 +1,15 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hay-kot/httpkit/errchain"
|
||||
"github.com/hay-kot/httpkit/graceful"
|
||||
@@ -28,7 +22,6 @@ import (
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/migrations"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/web/mid"
|
||||
|
||||
@@ -37,7 +30,6 @@ import (
|
||||
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/sqlite3"
|
||||
_ "github.com/sysadminsmedia/homebox/backend/pkgs/cgofreesqlite"
|
||||
|
||||
"gocloud.dev/pubsub"
|
||||
_ "gocloud.dev/pubsub/awssnssqs"
|
||||
_ "gocloud.dev/pubsub/azuresb"
|
||||
_ "gocloud.dev/pubsub/gcppubsub"
|
||||
@@ -102,41 +94,13 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func run(cfg *config.Config) error {
|
||||
app := new(cfg)
|
||||
app.setupLogger()
|
||||
|
||||
if cfg.Options.AllowAnalytics {
|
||||
analytics.Send(version, build())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialize Database & Repos
|
||||
|
||||
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
|
||||
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
|
||||
clean := filepath.Clean(raw)
|
||||
absBase, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string")
|
||||
}
|
||||
// Construct and validate the full storage path
|
||||
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
|
||||
// Set windows paths to use forward slashes required by go-cloud
|
||||
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
|
||||
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
|
||||
log.Fatal().
|
||||
Str("path", storageDir).
|
||||
Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
||||
}
|
||||
// Create with more restrictive permissions
|
||||
if err := os.MkdirAll(storageDir, 0o750); err != nil {
|
||||
log.Fatal().
|
||||
Err(err).
|
||||
Msg("failed to create data directory")
|
||||
}
|
||||
}
|
||||
setupStorageDir(cfg)
|
||||
|
||||
if strings.ToLower(cfg.Database.Driver) == "postgres" {
|
||||
if !validatePostgresSSLMode(cfg.Database.SslMode) {
|
||||
@@ -144,23 +108,7 @@ func run(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
// Set up the database URL based on the driver because for some reason a common URL format is not used
|
||||
databaseURL := ""
|
||||
switch strings.ToLower(cfg.Database.Driver) {
|
||||
case "sqlite3":
|
||||
databaseURL = cfg.Database.SqlitePath
|
||||
|
||||
// Create directory for SQLite database if it doesn't exist
|
||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0] // Remove query parameters
|
||||
dbDir := filepath.Dir(dbFilePath)
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
||||
}
|
||||
case "postgres":
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Username, cfg.Database.Password, cfg.Database.Database, cfg.Database.SslMode)
|
||||
default:
|
||||
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
||||
}
|
||||
databaseURL := setupDatabaseURL(cfg)
|
||||
|
||||
c, err := ent.Open(strings.ToLower(cfg.Database.Driver), databaseURL)
|
||||
if err != nil {
|
||||
@@ -186,25 +134,9 @@ func run(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
collectFuncs := []currencies.CollectorFunc{
|
||||
currencies.CollectDefaults(),
|
||||
}
|
||||
|
||||
if cfg.Options.CurrencyConfig != "" {
|
||||
log.Info().
|
||||
Str("path", cfg.Options.CurrencyConfig).
|
||||
Msg("loading currency config file")
|
||||
|
||||
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("path", cfg.Options.CurrencyConfig).
|
||||
Msg("failed to read currency config file")
|
||||
return err
|
||||
}
|
||||
|
||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
||||
collectFuncs, err := loadCurrencies(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
|
||||
@@ -262,150 +194,31 @@ func run(cfg *config.Config) error {
|
||||
return httpserver.ListenAndServe()
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Start Reoccurring Tasks
|
||||
registerRecurringTasks(app, cfg, runner)
|
||||
|
||||
runner.AddFunc("eventbus", app.bus.Run)
|
||||
|
||||
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
||||
// TODO: Remove through external API that does setup
|
||||
if cfg.Demo {
|
||||
log.Info().Msg("Running in demo mode, creating demo data")
|
||||
err := app.SetupDemo()
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
runner.AddPlugin(NewTask("purge-tokens", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to purge expired tokens")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to purge expired invitations")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) {
|
||||
now := time.Now()
|
||||
|
||||
if now.Hour() == 8 {
|
||||
fmt.Println("run notifiers")
|
||||
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to send notifiers")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
go runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
|
||||
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to generate pubsub connection string")
|
||||
return err
|
||||
}
|
||||
topic, err := pubsub.OpenTopic(ctx, pubsubString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Topic, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(topic, ctx)
|
||||
|
||||
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to open pubsub topic")
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Subscription, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(subscription, ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
msg, err := subscription.Receive(ctx)
|
||||
log.Debug().Msg("received thumbnail generation request from pubsub topic")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to receive message from pubsub topic")
|
||||
// Send analytics if enabled at around midnight UTC
|
||||
if cfg.Options.AllowAnalytics {
|
||||
analyticsTime := time.Second
|
||||
runner.AddPlugin(NewTask("send-analytics", analyticsTime, func(ctx context.Context) {
|
||||
for {
|
||||
now := time.Now().UTC()
|
||||
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
dur := time.Until(nextMidnight)
|
||||
analyticsTime = dur
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(dur):
|
||||
log.Debug().Msg("running send analytics")
|
||||
err := analytics.Send(version, build())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send analytics")
|
||||
}
|
||||
}
|
||||
groupId, err := uuid.Parse(msg.Metadata["group_id"])
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("group_id", msg.Metadata["group_id"]).
|
||||
Msg("failed to parse group ID from message metadata")
|
||||
}
|
||||
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("attachment_id", msg.Metadata["attachment_id"]).
|
||||
Msg("failed to parse attachment ID from message metadata")
|
||||
}
|
||||
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create thumbnail")
|
||||
}
|
||||
msg.Ack()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.Options.GithubReleaseCheck {
|
||||
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
|
||||
log.Debug().Msg("running get latest github release")
|
||||
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to get latest github release")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if cfg.Debug.Enabled {
|
||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
||||
debugserver := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
||||
Handler: app.debugRouter(),
|
||||
ReadTimeout: cfg.Web.ReadTimeout,
|
||||
WriteTimeout: cfg.Web.WriteTimeout,
|
||||
IdleTimeout: cfg.Web.IdleTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = debugserver.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
||||
return debugserver.ListenAndServe()
|
||||
})
|
||||
// Print the configuration to the console
|
||||
cfg.Print()
|
||||
}
|
||||
|
||||
return runner.Start(context.Background())
|
||||
}
|
||||
|
||||
150
backend/app/api/recurring.go
Normal file
150
backend/app/api/recurring.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/hay-kot/httpkit/graceful"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
|
||||
"gocloud.dev/pubsub"
|
||||
)
|
||||
|
||||
func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runner) {
|
||||
runner.AddFunc("eventbus", app.bus.Run)
|
||||
|
||||
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
||||
if cfg.Demo {
|
||||
log.Info().Msg("Running in demo mode, creating demo data")
|
||||
err := app.SetupDemo()
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
runner.AddPlugin(NewTask("purge-tokens", 24*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to purge expired tokens")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to purge expired invitations")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("send-notifications", time.Hour, func(ctx context.Context) {
|
||||
now := time.Now()
|
||||
if now.Hour() == 8 {
|
||||
fmt.Println("run notifiers")
|
||||
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send notifiers")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if cfg.Thumbnail.Enabled {
|
||||
runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
|
||||
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to generate pubsub connection string")
|
||||
return err
|
||||
}
|
||||
topic, err := pubsub.OpenTopic(ctx, pubsubString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Topic, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(topic, ctx)
|
||||
|
||||
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to open pubsub topic")
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Subscription, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(subscription, ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
msg, err := subscription.Receive(ctx)
|
||||
log.Debug().Msg("received thumbnail generation request from pubsub topic")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to receive message from pubsub topic")
|
||||
continue
|
||||
}
|
||||
if msg == nil {
|
||||
log.Warn().Msg("received nil message from pubsub topic")
|
||||
continue
|
||||
}
|
||||
groupId, err := uuid.Parse(msg.Metadata["group_id"])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("group_id", msg.Metadata["group_id"]).Msg("failed to parse group ID from message metadata")
|
||||
}
|
||||
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("attachment_id", msg.Metadata["attachment_id"]).Msg("failed to parse attachment ID from message metadata")
|
||||
}
|
||||
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create thumbnail")
|
||||
}
|
||||
msg.Ack()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Options.GithubReleaseCheck {
|
||||
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
|
||||
log.Debug().Msg("running get latest github release")
|
||||
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get latest github release")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if cfg.Debug.Enabled {
|
||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
||||
debugserver := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
||||
Handler: app.debugRouter(),
|
||||
ReadTimeout: cfg.Web.ReadTimeout,
|
||||
WriteTimeout: cfg.Web.WriteTimeout,
|
||||
IdleTimeout: cfg.Web.IdleTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = debugserver.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
||||
return debugserver.ListenAndServe()
|
||||
})
|
||||
// Print the configuration to the console
|
||||
cfg.Print()
|
||||
}
|
||||
}
|
||||
93
backend/app/api/setup.go
Normal file
93
backend/app/api/setup.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
)
|
||||
|
||||
// setupStorageDir handles the creation and validation of the storage directory.
|
||||
func setupStorageDir(cfg *config.Config) {
|
||||
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
|
||||
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
|
||||
clean := filepath.Clean(raw)
|
||||
absBase, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string")
|
||||
}
|
||||
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
|
||||
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
|
||||
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
|
||||
log.Fatal().Str("path", storageDir).Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
||||
}
|
||||
if err := os.MkdirAll(storageDir, 0o750); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to create data directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupDatabaseURL returns the database URL and ensures any required directories exist.
|
||||
func setupDatabaseURL(cfg *config.Config) string {
|
||||
databaseURL := ""
|
||||
switch strings.ToLower(cfg.Database.Driver) {
|
||||
case "sqlite3":
|
||||
databaseURL = cfg.Database.SqlitePath
|
||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0]
|
||||
dbDir := filepath.Dir(dbFilePath)
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
||||
}
|
||||
case "postgres":
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode)
|
||||
if cfg.Database.Username != "" {
|
||||
databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username)
|
||||
}
|
||||
if cfg.Database.Password != "" {
|
||||
databaseURL += fmt.Sprintf(" password=%s", cfg.Database.Password)
|
||||
}
|
||||
if cfg.Database.SslRootCert != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslRootCert); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslRootCert).Msg("SSL root certificate file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslrootcert=%s", cfg.Database.SslRootCert)
|
||||
}
|
||||
if cfg.Database.SslCert != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslCert); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslCert).Msg("SSL certificate file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslcert=%s", cfg.Database.SslCert)
|
||||
}
|
||||
if cfg.Database.SslKey != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslKey); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslKey).Msg("SSL key file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslkey=%s", cfg.Database.SslKey)
|
||||
}
|
||||
default:
|
||||
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
||||
}
|
||||
return databaseURL
|
||||
}
|
||||
|
||||
// loadCurrencies loads currency data from config if provided.
|
||||
func loadCurrencies(cfg *config.Config) ([]currencies.CollectorFunc, error) {
|
||||
collectFuncs := []currencies.CollectorFunc{
|
||||
currencies.CollectDefaults(),
|
||||
}
|
||||
if cfg.Options.CurrencyConfig != "" {
|
||||
log.Info().Str("path", cfg.Options.CurrencyConfig).Msg("loading currency config file")
|
||||
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("path", cfg.Options.CurrencyConfig).Msg("failed to read currency config file")
|
||||
return nil, err
|
||||
}
|
||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
||||
}
|
||||
return collectFuncs, nil
|
||||
}
|
||||
@@ -40,6 +40,7 @@ require (
|
||||
gocloud.dev/pubsub/rabbitpubsub v0.41.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/text v0.26.0
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
@@ -189,7 +190,6 @@ require (
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
|
||||
@@ -352,6 +352,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -375,6 +377,8 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ=
|
||||
github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
@@ -417,6 +421,10 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
||||
127
backend/internal/data/ent/item_predicates.go
Normal file
127
backend/internal/data/ent/item_predicates.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
)
|
||||
|
||||
// AccentInsensitiveContains creates a predicate that performs accent-insensitive text search.
|
||||
// It normalizes both the database field value and the search value for comparison.
|
||||
func AccentInsensitiveContains(field string, searchValue string) predicate.Item {
|
||||
if searchValue == "" {
|
||||
return predicate.Item(func(s *sql.Selector) {
|
||||
// Return a predicate that never matches if search is empty
|
||||
s.Where(sql.False())
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize the search value
|
||||
normalizedSearch := textutils.NormalizeSearchQuery(searchValue)
|
||||
|
||||
return predicate.Item(func(s *sql.Selector) {
|
||||
dialect := s.Dialect()
|
||||
|
||||
switch dialect {
|
||||
case "sqlite3":
|
||||
// For SQLite, we'll create a custom normalization function using REPLACE
|
||||
// to handle common accented characters
|
||||
normalizeFunc := buildSQLiteNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
case "postgres":
|
||||
// For PostgreSQL, try to use unaccent extension if available
|
||||
// Fall back to REPLACE-based normalization if not available
|
||||
normalizeFunc := buildPostgreSQLNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
default:
|
||||
// Default fallback using REPLACE for common accented characters
|
||||
normalizeFunc := buildGenericNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// buildSQLiteNormalizeExpression creates a SQLite expression to normalize accented characters
|
||||
func buildSQLiteNormalizeExpression(fieldExpr string) string {
|
||||
return buildGenericNormalizeExpression(fieldExpr)
|
||||
}
|
||||
|
||||
// buildPostgreSQLNormalizeExpression creates a PostgreSQL expression to normalize accented characters
|
||||
func buildPostgreSQLNormalizeExpression(fieldExpr string) string {
|
||||
// Use a CASE statement to check if unaccent function exists before using it
|
||||
// This prevents errors when the unaccent extension is not installed
|
||||
return "CASE WHEN EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'unaccent') " +
|
||||
"THEN unaccent(" + fieldExpr + ") " +
|
||||
"ELSE " + buildGenericNormalizeExpression(fieldExpr) + " END"
|
||||
}
|
||||
|
||||
// buildGenericNormalizeExpression creates a database-agnostic expression to normalize common accented characters
|
||||
func buildGenericNormalizeExpression(fieldExpr string) string {
|
||||
// Chain REPLACE functions to handle the most common accented characters
|
||||
// Focused on the most frequently used accents in Spanish, French, and Portuguese
|
||||
// Ordered by frequency of use for better performance
|
||||
normalized := fieldExpr
|
||||
|
||||
// Most common accented characters ordered by frequency
|
||||
commonAccents := []struct {
|
||||
from, to string
|
||||
}{
|
||||
// Spanish - most common
|
||||
{"á", "a"}, {"é", "e"}, {"í", "i"}, {"ó", "o"}, {"ú", "u"}, {"ñ", "n"},
|
||||
{"Á", "A"}, {"É", "E"}, {"Í", "I"}, {"Ó", "O"}, {"Ú", "U"}, {"Ñ", "N"},
|
||||
|
||||
// French - most common
|
||||
{"è", "e"}, {"ê", "e"}, {"à", "a"}, {"ç", "c"},
|
||||
{"È", "E"}, {"Ê", "E"}, {"À", "A"}, {"Ç", "C"},
|
||||
|
||||
// German umlauts and Portuguese - common
|
||||
{"ä", "a"}, {"ö", "o"}, {"ü", "u"}, {"ã", "a"}, {"õ", "o"},
|
||||
{"Ä", "A"}, {"Ö", "O"}, {"Ü", "U"}, {"Ã", "A"}, {"Õ", "O"},
|
||||
}
|
||||
|
||||
for _, accent := range commonAccents {
|
||||
normalized = "REPLACE(" + normalized + ", '" + accent.from + "', '" + accent.to + "')"
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ItemNameAccentInsensitiveContains creates an accent-insensitive search predicate for the item name field.
|
||||
func ItemNameAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldName, value)
|
||||
}
|
||||
|
||||
// ItemDescriptionAccentInsensitiveContains creates an accent-insensitive search predicate for the item description field.
|
||||
func ItemDescriptionAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldDescription, value)
|
||||
}
|
||||
|
||||
// ItemSerialNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item serial number field.
|
||||
func ItemSerialNumberAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldSerialNumber, value)
|
||||
}
|
||||
|
||||
// ItemModelNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item model number field.
|
||||
func ItemModelNumberAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldModelNumber, value)
|
||||
}
|
||||
|
||||
// ItemManufacturerAccentInsensitiveContains creates an accent-insensitive search predicate for the item manufacturer field.
|
||||
func ItemManufacturerAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldManufacturer, value)
|
||||
}
|
||||
|
||||
// ItemNotesAccentInsensitiveContains creates an accent-insensitive search predicate for the item notes field.
|
||||
func ItemNotesAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldNotes, value)
|
||||
}
|
||||
156
backend/internal/data/ent/item_predicates_test.go
Normal file
156
backend/internal/data/ent/item_predicates_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildGenericNormalizeExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple field name",
|
||||
field: "name",
|
||||
expected: "name", // Should be wrapped in many REPLACE functions
|
||||
},
|
||||
{
|
||||
name: "Complex field name",
|
||||
field: "description",
|
||||
expected: "description",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildGenericNormalizeExpression(tt.field)
|
||||
|
||||
// Should contain the original field
|
||||
assert.Contains(t, result, tt.field)
|
||||
|
||||
// Should contain REPLACE functions for accent normalization
|
||||
assert.Contains(t, result, "REPLACE(")
|
||||
|
||||
// Should handle common accented characters
|
||||
assert.Contains(t, result, "'á'", "Should handle Spanish á")
|
||||
assert.Contains(t, result, "'é'", "Should handle Spanish é")
|
||||
assert.Contains(t, result, "'ñ'", "Should handle Spanish ñ")
|
||||
assert.Contains(t, result, "'ü'", "Should handle German ü")
|
||||
|
||||
// Should handle uppercase accents too
|
||||
assert.Contains(t, result, "'Á'", "Should handle uppercase Spanish Á")
|
||||
assert.Contains(t, result, "'É'", "Should handle uppercase Spanish É")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteNormalizeExpression(t *testing.T) {
|
||||
result := buildSQLiteNormalizeExpression("test_field")
|
||||
|
||||
// Should contain the field name and REPLACE functions
|
||||
assert.Contains(t, result, "test_field")
|
||||
assert.Contains(t, result, "REPLACE(")
|
||||
// Check for some specific accent replacements (order doesn't matter)
|
||||
assert.Contains(t, result, "'á'", "Should handle Spanish á")
|
||||
assert.Contains(t, result, "'ó'", "Should handle Spanish ó")
|
||||
}
|
||||
|
||||
func TestPostgreSQLNormalizeExpression(t *testing.T) {
|
||||
result := buildPostgreSQLNormalizeExpression("test_field")
|
||||
|
||||
// Should contain unaccent function and CASE WHEN logic
|
||||
assert.Contains(t, result, "unaccent(")
|
||||
assert.Contains(t, result, "CASE WHEN EXISTS")
|
||||
assert.Contains(t, result, "test_field")
|
||||
}
|
||||
|
||||
func TestAccentInsensitivePredicateCreation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field string
|
||||
searchValue string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Normal search value",
|
||||
field: "name",
|
||||
searchValue: "electronica",
|
||||
description: "Should create predicate for normal search",
|
||||
},
|
||||
{
|
||||
name: "Accented search value",
|
||||
field: "description",
|
||||
searchValue: "electrónica",
|
||||
description: "Should create predicate for accented search",
|
||||
},
|
||||
{
|
||||
name: "Empty search value",
|
||||
field: "name",
|
||||
searchValue: "",
|
||||
description: "Should handle empty search gracefully",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
predicate := AccentInsensitiveContains(tt.field, tt.searchValue)
|
||||
assert.NotNil(t, predicate, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecificItemPredicates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
predicateFunc func(string) interface{}
|
||||
searchValue string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ItemNameAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemNameAccentInsensitiveContains(val) },
|
||||
searchValue: "electronica",
|
||||
description: "Should create accent-insensitive name search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemDescriptionAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemDescriptionAccentInsensitiveContains(val) },
|
||||
searchValue: "descripcion",
|
||||
description: "Should create accent-insensitive description search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemManufacturerAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemManufacturerAccentInsensitiveContains(val) },
|
||||
searchValue: "compañia",
|
||||
description: "Should create accent-insensitive manufacturer search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemSerialNumberAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemSerialNumberAccentInsensitiveContains(val) },
|
||||
searchValue: "sn123",
|
||||
description: "Should create accent-insensitive serial number search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemModelNumberAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemModelNumberAccentInsensitiveContains(val) },
|
||||
searchValue: "model456",
|
||||
description: "Should create accent-insensitive model number search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemNotesAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemNotesAccentInsensitiveContains(val) },
|
||||
searchValue: "notas importantes",
|
||||
description: "Should create accent-insensitive notes search predicate",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
predicate := tt.predicateFunc(tt.searchValue)
|
||||
assert.NotNil(t, predicate, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -319,16 +319,19 @@ func (r *AttachmentRepo) Update(ctx context.Context, gid uuid.UUID, id uuid.UUID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure all other attachments are not primary
|
||||
err = r.db.Attachment.Update().
|
||||
Where(
|
||||
attachment.HasItemWith(item.ID(attachmentItem.ID)),
|
||||
attachment.IDNEQ(updatedAttachment.ID),
|
||||
).
|
||||
SetPrimary(false).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// Only remove primary status from other photo attachments when setting a new photo as primary
|
||||
if typ == attachment.TypePhoto && data.Primary {
|
||||
err = r.db.Attachment.Update().
|
||||
Where(
|
||||
attachment.HasItemWith(item.ID(attachmentItem.ID)),
|
||||
attachment.IDNEQ(updatedAttachment.ID),
|
||||
attachment.TypeEQ(attachment.TypePhoto),
|
||||
).
|
||||
SetPrimary(false).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return r.Get(ctx, gid, updatedAttachment.ID)
|
||||
|
||||
@@ -152,3 +152,132 @@ func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) {
|
||||
setAndVerifyPrimary(attachments[0].ID, attachments[1].ID)
|
||||
setAndVerifyPrimary(attachments[1].ID, attachments[0].ID)
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_UpdateNonPhotoDoesNotAffectPrimaryPhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Create a photo attachment that will be primary
|
||||
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a manual attachment (non-photo)
|
||||
manualAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Manual", Content: strings.NewReader("Manual content")}, attachment.TypeManual, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, manualAttachment.ID)
|
||||
})
|
||||
|
||||
// Verify photo is primary initially
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary)
|
||||
|
||||
// Update the manual attachment (this should NOT affect the photo's primary status)
|
||||
_, err = tRepos.Attachments.Update(ctx, tGroup.ID, manualAttachment.ID, &ItemAttachmentUpdate{
|
||||
Type: attachment.TypeManual.String(),
|
||||
Title: "Updated Manual",
|
||||
Primary: false, // This should have no effect since it's not a photo
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify photo is still primary after updating the manual
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "Photo attachment should remain primary after updating non-photo attachment")
|
||||
|
||||
// Verify manual attachment is not primary
|
||||
manualAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, manualAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, manualAttachment.Primary)
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Step 1: Upload a photo first (this should become primary since it's the first photo)
|
||||
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Item Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
|
||||
})
|
||||
|
||||
// Verify photo becomes primary automatically (since it's the first photo)
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "First photo should automatically become primary")
|
||||
|
||||
// Step 2: Add a PDF receipt (this should NOT affect the photo's primary status)
|
||||
pdfAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Receipt PDF", Content: strings.NewReader("PDF content")}, attachment.TypeReceipt, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add to cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, pdfAttachment.ID)
|
||||
})
|
||||
|
||||
// Step 3: Verify photo is still primary after adding PDF
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "Photo should remain primary after adding PDF attachment")
|
||||
|
||||
// Verify PDF is not primary
|
||||
pdfAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, pdfAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, pdfAttachment.Primary)
|
||||
|
||||
// Step 4: Test the actual item summary mapping (this is what determines the card display)
|
||||
updatedItem, err := tRepos.Items.GetOne(ctx, item.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The item should have the photo's ID as the imageId
|
||||
assert.NotNil(t, updatedItem.ImageID, "Item should have an imageId")
|
||||
assert.Equal(t, photoAttachment.ID, *updatedItem.ImageID, "Item's imageId should match the photo attachment ID")
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Create two photo attachments
|
||||
photo1, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 1", Content: strings.NewReader("Photo 1 content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
photo2, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 2", Content: strings.NewReader("Photo 2 content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo1.ID)
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo2.ID)
|
||||
})
|
||||
|
||||
// First photo should be primary (since it was created first)
|
||||
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photo1.Primary)
|
||||
|
||||
photo2, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, photo2.Primary)
|
||||
|
||||
// Now set photo2 as primary (this should work and remove primary from photo1)
|
||||
photo2, err = tRepos.Attachments.Update(ctx, tGroup.ID, photo2.ID, &ItemAttachmentUpdate{
|
||||
Type: attachment.TypePhoto.String(),
|
||||
Title: "Photo 2",
|
||||
Primary: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photo2.Primary)
|
||||
|
||||
// Verify photo1 is no longer primary
|
||||
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, photo1.Primary, "Photo 1 should no longer be primary after setting Photo 2 as primary")
|
||||
}
|
||||
|
||||
@@ -360,14 +360,25 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
|
||||
}
|
||||
|
||||
if q.Search != "" {
|
||||
// Use accent-insensitive search predicates that normalize both
|
||||
// the search query and database field values during comparison.
|
||||
// For queries without accents, the traditional search is more efficient.
|
||||
qb.Where(
|
||||
item.Or(
|
||||
// Regular case-insensitive search (fastest)
|
||||
item.NameContainsFold(q.Search),
|
||||
item.DescriptionContainsFold(q.Search),
|
||||
item.SerialNumberContainsFold(q.Search),
|
||||
item.ModelNumberContainsFold(q.Search),
|
||||
item.ManufacturerContainsFold(q.Search),
|
||||
item.NotesContainsFold(q.Search),
|
||||
// Accent-insensitive search using custom predicates
|
||||
ent.ItemNameAccentInsensitiveContains(q.Search),
|
||||
ent.ItemDescriptionAccentInsensitiveContains(q.Search),
|
||||
ent.ItemSerialNumberAccentInsensitiveContains(q.Search),
|
||||
ent.ItemModelNumberAccentInsensitiveContains(q.Search),
|
||||
ent.ItemManufacturerAccentInsensitiveContains(q.Search),
|
||||
ent.ItemNotesAccentInsensitiveContains(q.Search),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
213
backend/internal/data/repo/repo_items_search_test.go
Normal file
213
backend/internal/data/repo/repo_items_search_test.go
Normal file
@@ -0,0 +1,213 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
|
||||
// Test cases for accent-insensitive search
|
||||
testCases := []struct {
|
||||
name string
|
||||
itemName string
|
||||
searchQuery string
|
||||
shouldMatch bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Spanish accented item, search without accents",
|
||||
itemName: "electrónica",
|
||||
searchQuery: "electronica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electrónica' when searching for 'electronica'",
|
||||
},
|
||||
{
|
||||
name: "Spanish accented item, search with accents",
|
||||
itemName: "electrónica",
|
||||
searchQuery: "electrónica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electrónica' when searching for 'electrónica'",
|
||||
},
|
||||
{
|
||||
name: "Non-accented item, search with accents",
|
||||
itemName: "electronica",
|
||||
searchQuery: "electrónica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electronica' when searching for 'electrónica' (bidirectional search)",
|
||||
},
|
||||
{
|
||||
name: "Spanish item with tilde, search without accents",
|
||||
itemName: "café",
|
||||
searchQuery: "cafe",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'café' when searching for 'cafe'",
|
||||
},
|
||||
{
|
||||
name: "Spanish item without tilde, search with accents",
|
||||
itemName: "cafe",
|
||||
searchQuery: "café",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French accented item, search without accents",
|
||||
itemName: "pére",
|
||||
searchQuery: "pere",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'pére' when searching for 'pere'",
|
||||
},
|
||||
{
|
||||
name: "French: père without accent, search with accents",
|
||||
itemName: "pere",
|
||||
searchQuery: "père",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'pere' when searching for 'père' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Mixed case with accents",
|
||||
itemName: "Electrónica",
|
||||
searchQuery: "ELECTRONICA",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'Electrónica' when searching for 'ELECTRONICA' (case insensitive)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Non-accented item, search with different accents",
|
||||
itemName: "cafe",
|
||||
searchQuery: "café",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Item with accent, search with different accent",
|
||||
itemName: "résumé",
|
||||
searchQuery: "resume",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'résumé' when searching for 'resume' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Spanish ñ to n",
|
||||
itemName: "espanol",
|
||||
searchQuery: "español",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'espanol' when searching for 'español' (bidirectional ñ)",
|
||||
},
|
||||
{
|
||||
name: "French: français with accent, search without",
|
||||
itemName: "français",
|
||||
searchQuery: "francais",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'français' when searching for 'francais'",
|
||||
},
|
||||
{
|
||||
name: "French: français without accent, search with",
|
||||
itemName: "francais",
|
||||
searchQuery: "français",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'francais' when searching for 'français' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: été with accent, search without",
|
||||
itemName: "été",
|
||||
searchQuery: "ete",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'été' when searching for 'ete'",
|
||||
},
|
||||
{
|
||||
name: "French: été without accent, search with",
|
||||
itemName: "ete",
|
||||
searchQuery: "été",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'ete' when searching for 'été' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: hôtel with accent, search without",
|
||||
itemName: "hôtel",
|
||||
searchQuery: "hotel",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'hôtel' when searching for 'hotel'",
|
||||
},
|
||||
{
|
||||
name: "French: hôtel without accent, search with",
|
||||
itemName: "hotel",
|
||||
searchQuery: "hôtel",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'hotel' when searching for 'hôtel' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: naïve with accent, search without",
|
||||
itemName: "naïve",
|
||||
searchQuery: "naive",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'naïve' when searching for 'naive'",
|
||||
},
|
||||
{
|
||||
name: "French: naïve without accent, search with",
|
||||
itemName: "naive",
|
||||
searchQuery: "naïve",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'naive' when searching for 'naïve' (bidirectional)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test the normalization logic used in the repository
|
||||
normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery)
|
||||
|
||||
// This simulates what happens in the repository
|
||||
// The original search would find exact matches (case-insensitive)
|
||||
// The normalized search would find accent-insensitive matches
|
||||
|
||||
// Test that our normalization works as expected
|
||||
if tc.shouldMatch {
|
||||
// If it should match, then either the original query should match
|
||||
// or the normalized query should match when applied to the stored data
|
||||
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
|
||||
|
||||
// The key insight is that we're searching with both the original and normalized queries
|
||||
// So "electrónica" will be found when searching for "electronica" because:
|
||||
// 1. Original search: "electronica" doesn't match "electrónica"
|
||||
// 2. Normalized search: "electronica" matches the normalized version
|
||||
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
} else {
|
||||
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSearchQueryIntegration(t *testing.T) {
|
||||
// Test that the normalization function works correctly
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"electrónica", "electronica"},
|
||||
{"café", "cafe"},
|
||||
{"ELECTRÓNICA", "electronica"},
|
||||
{"Café París", "cafe paris"},
|
||||
{"hello world", "hello world"},
|
||||
// French accented words
|
||||
{"père", "pere"},
|
||||
{"français", "francais"},
|
||||
{"été", "ete"},
|
||||
{"hôtel", "hotel"},
|
||||
{"naïve", "naive"},
|
||||
{"PÈRE", "pere"},
|
||||
{"FRANÇAIS", "francais"},
|
||||
{"ÉTÉ", "ete"},
|
||||
{"HÔTEL", "hotel"},
|
||||
{"NAÏVE", "naive"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := textutils.NormalizeSearchQuery(tc.input)
|
||||
assert.Equal(t, tc.expected, result, "Normalization should work correctly")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
type Data struct {
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
@@ -18,7 +20,7 @@ type Data struct {
|
||||
Props map[string]interface{} `json:"props"`
|
||||
}
|
||||
|
||||
func Send(version, buildInfo string) {
|
||||
func Send(version, buildInfo string) error {
|
||||
hostData, _ := host.Info()
|
||||
analytics := Data{
|
||||
Domain: "homebox.software",
|
||||
@@ -32,22 +34,23 @@ func Send(version, buildInfo string) {
|
||||
"platform_version": hostData.PlatformVersion,
|
||||
"kernel_arch": hostData.KernelArch,
|
||||
"virt_type": hostData.VirtualizationSystem,
|
||||
"uptime_min": time.Since(startTime).Minutes(),
|
||||
},
|
||||
}
|
||||
jsonBody, err := json.Marshal(analytics)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to marshal analytics data")
|
||||
return
|
||||
return err
|
||||
}
|
||||
bodyReader := bytes.NewReader(jsonBody)
|
||||
req, err := http.NewRequest("POST", "https://a.sysadmins.zone/api/event", bodyReader)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create analytics request")
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Homebox/"+version+"/"+buildInfo+" (https://homebox.software)")
|
||||
req.Header.Set("User-Agent", "Homebox/"+version+"/(https://homebox.software)")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -56,7 +59,7 @@ func Send(version, buildInfo string) {
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send analytics request")
|
||||
return
|
||||
return err
|
||||
}
|
||||
|
||||
defer func() {
|
||||
@@ -65,4 +68,5 @@ func Send(version, buildInfo string) {
|
||||
log.Error().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,6 +18,9 @@ type Database struct {
|
||||
Port string `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
SslMode string `yaml:"ssl_mode"`
|
||||
SslRootCert string `yaml:"ssl_rootcert"`
|
||||
SslCert string `yaml:"ssl_cert"`
|
||||
SslKey string `yaml:"ssl_key"`
|
||||
SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"`
|
||||
PubSubConnString string `yaml:"pubsub_conn_string" conf:"default:mem://{{ .Topic }}"`
|
||||
}
|
||||
|
||||
@@ -303,8 +303,27 @@ func PrintLabel(cfg *config.Config, params *GenerateParameters) error {
|
||||
|
||||
commandTemplate := template.Must(template.New("command").Parse(*cfg.LabelMaker.PrintCommand))
|
||||
builder := &strings.Builder{}
|
||||
additionalInformation := func() string {
|
||||
if params.AdditionalInformation != nil {
|
||||
return *params.AdditionalInformation
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
if err := commandTemplate.Execute(builder, map[string]string{
|
||||
"FileName": f.Name(),
|
||||
"FileName": f.Name(),
|
||||
"Width": fmt.Sprintf("%d", params.Width),
|
||||
"Height": fmt.Sprintf("%d", params.Height),
|
||||
"QrSize": fmt.Sprintf("%d", params.QrSize),
|
||||
"Margin": fmt.Sprintf("%d", params.Margin),
|
||||
"ComponentPadding": fmt.Sprintf("%d", params.ComponentPadding),
|
||||
"TitleText": params.TitleText,
|
||||
"TitleFontSize": fmt.Sprintf("%f", params.TitleFontSize),
|
||||
"DescriptionText": params.DescriptionText,
|
||||
"DescriptionFontSize": fmt.Sprintf("%f", params.DescriptionFontSize),
|
||||
"AdditionalInformation": additionalInformation,
|
||||
"Dpi": fmt.Sprintf("%f", params.Dpi),
|
||||
"URL": params.URL,
|
||||
"DynamicLength": fmt.Sprintf("%t", params.DynamicLength),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
40
backend/pkgs/textutils/normalize.go
Normal file
40
backend/pkgs/textutils/normalize.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package textutils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// RemoveAccents removes accents from text by normalizing Unicode characters
|
||||
// and removing diacritical marks. This allows for accent-insensitive search.
|
||||
//
|
||||
// Example:
|
||||
// - "electrónica" becomes "electronica"
|
||||
// - "café" becomes "cafe"
|
||||
// - "père" becomes "pere"
|
||||
func RemoveAccents(text string) string {
|
||||
// Create a transformer that:
|
||||
// 1. Normalizes to NFD (canonical decomposition)
|
||||
// 2. Removes diacritical marks (combining characters)
|
||||
// 3. Normalizes back to NFC (canonical composition)
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
|
||||
result, _, err := transform.String(t, text)
|
||||
if err != nil {
|
||||
// If transformation fails, return the original text
|
||||
return text
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// NormalizeSearchQuery normalizes a search query for accent-insensitive matching.
|
||||
// This function removes accents and converts to lowercase for consistent search behavior.
|
||||
func NormalizeSearchQuery(query string) string {
|
||||
normalized := RemoveAccents(query)
|
||||
return strings.ToLower(normalized)
|
||||
}
|
||||
152
backend/pkgs/textutils/normalize_test.go
Normal file
152
backend/pkgs/textutils/normalize_test.go
Normal file
@@ -0,0 +1,152 @@
|
||||
package textutils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveAccents(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Spanish accented characters",
|
||||
input: "electrónica",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Spanish accented characters with tilde",
|
||||
input: "café",
|
||||
expected: "cafe",
|
||||
},
|
||||
{
|
||||
name: "French accented characters",
|
||||
input: "père",
|
||||
expected: "pere",
|
||||
},
|
||||
{
|
||||
name: "German umlauts",
|
||||
input: "Björk",
|
||||
expected: "Bjork",
|
||||
},
|
||||
{
|
||||
name: "Mixed accented characters",
|
||||
input: "résumé",
|
||||
expected: "resume",
|
||||
},
|
||||
{
|
||||
name: "Portuguese accented characters",
|
||||
input: "João",
|
||||
expected: "Joao",
|
||||
},
|
||||
{
|
||||
name: "No accents",
|
||||
input: "hello world",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Numbers and symbols",
|
||||
input: "123!@#",
|
||||
expected: "123!@#",
|
||||
},
|
||||
{
|
||||
name: "Multiple accents in one word",
|
||||
input: "été",
|
||||
expected: "ete",
|
||||
},
|
||||
{
|
||||
name: "Complex Unicode characters",
|
||||
input: "français",
|
||||
expected: "francais",
|
||||
},
|
||||
{
|
||||
name: "Unicode diacritics",
|
||||
input: "naïve",
|
||||
expected: "naive",
|
||||
},
|
||||
{
|
||||
name: "Unicode combining characters",
|
||||
input: "e\u0301", // e with combining acute accent
|
||||
expected: "e",
|
||||
},
|
||||
{
|
||||
name: "Very long string with accents",
|
||||
input: strings.Repeat("café", 1000),
|
||||
expected: strings.Repeat("cafe", 1000),
|
||||
},
|
||||
{
|
||||
name: "All French accents",
|
||||
input: "àâäéèêëïîôöùûüÿç",
|
||||
expected: "aaaeeeeiioouuuyc",
|
||||
},
|
||||
{
|
||||
name: "All Spanish accents",
|
||||
input: "áéíóúñüÁÉÍÓÚÑÜ",
|
||||
expected: "aeiounuAEIOUNU",
|
||||
},
|
||||
{
|
||||
name: "All German umlauts",
|
||||
input: "äöüÄÖÜß",
|
||||
expected: "aouAOUß",
|
||||
},
|
||||
{
|
||||
name: "Mixed languages",
|
||||
input: "Français café España niño",
|
||||
expected: "Francais cafe Espana nino",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := RemoveAccents(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("RemoveAccents(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSearchQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Uppercase with accents",
|
||||
input: "ELECTRÓNICA",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Mixed case with accents",
|
||||
input: "Electrónica",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Multiple words with accents",
|
||||
input: "Café París",
|
||||
expected: "cafe paris",
|
||||
},
|
||||
{
|
||||
name: "No accents mixed case",
|
||||
input: "Hello World",
|
||||
expected: "hello world",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := NormalizeSearchQuery(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("NormalizeSearchQuery(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -35,10 +35,13 @@ aside: false
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_HOST | | sets the hostname for a postgres database |
|
||||
| HBOX_DATABASE_PORT | | sets the port for a postgres database |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_DATABASE | | sets the database for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_CERT | | sets the sslcert for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_KEY | | sets the sslkey for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_ROOTCERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases |
|
||||
| HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |
|
||||
|
||||
4
docs/public/_headers
Normal file
4
docs/public/_headers
Normal file
@@ -0,0 +1,4 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Content-Security-Policy: default-src 'self'; script-src 'report-sample' 'unsafe-inline' 'self' https://a.sysadmins.zone/js/embed.host.js https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015 https://unpkg.com/@stoplight/elements/web-components.min.js; style-src 'report-sample' 'unsafe-inline' 'self' https://unpkg.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://raw.githubusercontent.com; font-src 'self'; frame-src 'self' https://a.sysadmins.zone; img-src 'self' data: http://translate.sysadminsmedia.com; manifest-src 'self'; media-src 'self'; worker-src 'none';
|
||||
7
docs/wrangler.toml
Normal file
7
docs/wrangler.toml
Normal file
@@ -0,0 +1,7 @@
|
||||
name = "homebox-docs"
|
||||
compatibility_date = "2025-07-12"
|
||||
preview_urls = true
|
||||
|
||||
[assets]
|
||||
directory = ".vitepress/dist"
|
||||
not_found_handling = "single-page-application"
|
||||
@@ -33,16 +33,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import { Dialog, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { activeDialog } = useDialog();
|
||||
const { activeDialog, closeDialog } = useDialog();
|
||||
const open = computed(() => activeDialog.value === "scanner");
|
||||
|
||||
const sources = ref<MediaDeviceInfo[]>([]);
|
||||
@@ -129,6 +129,7 @@
|
||||
throw new Error(t("scanner.invalid_url"));
|
||||
}
|
||||
const sanitizedPath = url.pathname.replace(/[^a-zA-Z0-9-_/]/g, "");
|
||||
closeDialog("scanner");
|
||||
navigateTo(sanitizedPath);
|
||||
} catch (err) {
|
||||
loading.value = false;
|
||||
|
||||
@@ -130,8 +130,14 @@
|
||||
}))
|
||||
.filter(i => !modelValue.value.includes(i.value));
|
||||
|
||||
// Only show "Create" option if search term is not empty and no exact match exists
|
||||
if (searchTerm.value.trim() !== "") {
|
||||
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
|
||||
const trimmedSearchTerm = searchTerm.value.trim();
|
||||
const hasExactMatch = props.labels.some(label => label.name.toLowerCase() === trimmedSearchTerm.toLowerCase());
|
||||
|
||||
if (!hasExactMatch) {
|
||||
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
|
||||
}
|
||||
}
|
||||
|
||||
return filtered;
|
||||
|
||||
@@ -55,18 +55,10 @@
|
||||
() => activeDialog.value,
|
||||
active => {
|
||||
if (active === "create-location") {
|
||||
// useTimeoutFn(() => {
|
||||
// focused.value = true;
|
||||
// }, 50);
|
||||
|
||||
if (locationId.value) {
|
||||
const found = locations.value.find(l => l.id === locationId.value);
|
||||
if (found) {
|
||||
form.parent = found;
|
||||
}
|
||||
form.parent = found || null;
|
||||
}
|
||||
} else {
|
||||
// focused.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -74,7 +66,6 @@
|
||||
function reset() {
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.parent = null;
|
||||
focused.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -118,6 +109,7 @@
|
||||
if (data) {
|
||||
toast.success(t("components.location.create_modal.toast.create_success"));
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
if (close) {
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
{
|
||||
"components": {
|
||||
"app": {
|
||||
"create_modal": {
|
||||
"createAndAddAnother": "Brug {shiftKey} + {enterKey} til at oprette og tilføje en ny.",
|
||||
"enter": "Indtast",
|
||||
"shift": "Flytte"
|
||||
},
|
||||
"import_dialog": {
|
||||
"change_warning": "Adfærd for imports med eksisterende import_refs har ændret sig. Hvis en import_ref er tilstede i CSV filen,\nvil genstanden blive opdateret med værdierne fra CSV filen.",
|
||||
"description": "Importer en CSV fil som indeholder dine genstande, etiketter, og lokationer. Se dokumentation for mere information vedrørende\nden korrekte format.",
|
||||
"title": "Importer CSV Fil"
|
||||
"title": "Importer CSV Fil",
|
||||
"toast": {
|
||||
"import_failed": "Importen mislykkedes. Prøv igen senere.",
|
||||
"import_success": "Importen er gennemført!",
|
||||
"please_select_file": "Vælg venligst en fil, der skal importeres."
|
||||
}
|
||||
},
|
||||
"outdated": {
|
||||
"current_version": "Nuværende version",
|
||||
@@ -14,6 +24,18 @@
|
||||
"new_version_available_link": "Klik her for at læse udgivelsesnoterne"
|
||||
}
|
||||
},
|
||||
"color_selector": {
|
||||
"clear": "Reset farve",
|
||||
"color": "Farve",
|
||||
"no_color": "Ingen farve",
|
||||
"no_color_selected": "Ingen farve valgt",
|
||||
"randomize": "Tilfældig farve"
|
||||
},
|
||||
"form": {
|
||||
"password": {
|
||||
"toggle_show": "Slå adgangskode til/fra Vis"
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"copy_text": {
|
||||
"documentation": "Dokumentation",
|
||||
@@ -51,7 +73,12 @@
|
||||
"download": "Hent label",
|
||||
"print": "Print label",
|
||||
"server_print": "Print på Server",
|
||||
"titles": "Labels"
|
||||
"titles": "Labels",
|
||||
"toast": {
|
||||
"load_status_failed": "Status kunne ikke indlæses",
|
||||
"print_failed": "Kunne ikke udskrive etiketten",
|
||||
"print_success": "Etiket udskrevet"
|
||||
}
|
||||
},
|
||||
"page_qr_code": {
|
||||
"page_url": "Side URL",
|
||||
@@ -62,12 +89,41 @@
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"attachments_list": {
|
||||
"download": "Download",
|
||||
"open_new_tab": "Åbn i ny fane"
|
||||
},
|
||||
"create_modal": {
|
||||
"delete_photo": "Slet billede",
|
||||
"item_description": "Genstandsbeskrivelse",
|
||||
"item_name": "Genstandsnavn",
|
||||
"item_photo": "Vare Foto 📷",
|
||||
"item_quantity": "Vare Antal",
|
||||
"parent_item": "Overordnet element",
|
||||
"rotate_photo": "Roter foto",
|
||||
"set_as_primary_photo": "Sæt som { isPrimary, select, true {non-} false {} other {}}primært foto",
|
||||
"title": "Opret genstand",
|
||||
"upload_photos": "Upload Billeder"
|
||||
"toast": {
|
||||
"already_creating": "Opretter allerede et element",
|
||||
"create_failed": "Kunne ikke oprette elementet",
|
||||
"create_success": "Element oprettet",
|
||||
"failed_load_parent": "Kunne ikke indlæse overordnet element - vælg venligst manuelt",
|
||||
"no_canvas_support": "Din browser understøtter ikke canvas-handlinger",
|
||||
"please_select_location": "Vælg venligst en placering.",
|
||||
"rotate_failed": "Kunne ikke rotere billedet: { error }",
|
||||
"rotate_process_failed": "Kunne ikke behandle roteret billede",
|
||||
"some_photos_failed": "{count, plural, =0 {Ingen billeder at uploade.} =1 {1 billede kunne ikke uploades.} other {Nogle billeder kunne ikke uploades.}}",
|
||||
"upload_failed": "Kunne ikke uploade billede: { photoName }",
|
||||
"upload_success": "{count, plural, =0 {Ingen billeder uploadet.} =1 {Foto uploadet.} other {Alle billeder uploadet.}}",
|
||||
"uploading_photos": "{count, plural, =0 {Ingen billeder at uploade} =1 {Uploader 1 billede…} other {Uploader {count} billeder…}}"
|
||||
},
|
||||
"upload_photos": "Upload Billeder",
|
||||
"uploaded": "Uploadet billede"
|
||||
},
|
||||
"selector": {
|
||||
"no_results": "Ingen resultater fundet",
|
||||
"placeholder": "Vælg…",
|
||||
"search_placeholder": "Skriv for at søge…"
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
@@ -77,17 +133,26 @@
|
||||
"table": "Tabel"
|
||||
},
|
||||
"table": {
|
||||
"headers": "Overskrifter",
|
||||
"page": "Side",
|
||||
"rows_per_page": "Rækker per side",
|
||||
"table_settings": "Tabel Indstillinger"
|
||||
"table_settings": "Tabel Indstillinger",
|
||||
"view_item": "Se vare"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"create_modal": {
|
||||
"label_color": "Etiketfarve",
|
||||
"label_description": "Etiketbeskrivelse",
|
||||
"label_name": "Etiketnavn",
|
||||
"title": "Opret label"
|
||||
"title": "Opret label",
|
||||
"toast": {
|
||||
"already_creating": "Allerede oprettet en etiket",
|
||||
"create_failed": "Kunne ikke oprette etiket",
|
||||
"create_success": "Etiket oprettet",
|
||||
"label_name_too_long": "Etiketnavnet må ikke være længere end 50 tegn"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"select_labels": "Vælg Etiketter"
|
||||
@@ -97,47 +162,71 @@
|
||||
"create_modal": {
|
||||
"location_description": "Lokationsbeskrivelse",
|
||||
"location_name": "Lokationsnavn",
|
||||
"title": "Opret lokation"
|
||||
"title": "Opret lokation",
|
||||
"toast": {
|
||||
"already_creating": "Allerede oprettet en lokation",
|
||||
"create_failed": "Kunne ikke oprette placering",
|
||||
"create_success": "Placering oprettet"
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"parent_location": "Forældrelokation"
|
||||
"no_location_found": "Ingen placering fundet",
|
||||
"parent_location": "Forældrelokation",
|
||||
"search_location": "Søg efter placeringer",
|
||||
"select_location": "Vælg en placering"
|
||||
},
|
||||
"tree": {
|
||||
"no_locations": "Ingen tilgængelige lokationer. Opret nye lokationer gennem\n`<`span class=\"link-primary\">`Opret`<`/span`>` knappen i navigationslinjen."
|
||||
"no_locations": "Ingen tilgængelige placeringer. Tilføj nye placeringer via knappen '<span class=\"link-primary\">'Opret'</span>' på navigationslinjen."
|
||||
}
|
||||
},
|
||||
"quick_menu": {
|
||||
"no_results": "Ingen resultater fundet.",
|
||||
"shortcut_hint": "Brug de numeriske taster til hurtigt at vælge en handling."
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"add": "Tilføj",
|
||||
"archived": "Arkiveret",
|
||||
"build": "Build: { build }",
|
||||
"cancel": "Ophæv",
|
||||
"confirm": "Bekræft",
|
||||
"create": "Opret",
|
||||
"create_and_add": "Opret og tilføj ny",
|
||||
"create_subitem": "Opret underelement",
|
||||
"created": "Oprettet",
|
||||
"delete": "Slet",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette dette element? ",
|
||||
"demo_instance": "Dette er en demo-instans",
|
||||
"details": "Detaljer",
|
||||
"duplicate": "Dupliker",
|
||||
"edit": "Rediger",
|
||||
"email": "Email",
|
||||
"follow_dev": "Følg udvikleren",
|
||||
"footer": {
|
||||
"api_link": "'<a href=\"https://homebox.software/en/api/\" target=\"_blank\">'API'</a>'",
|
||||
"version_link": "'<'a href=\"https://github.com/sysadminsmedia/homebox/releases/tag/{ version }\" target=\"_blank\"'>' Version: { version } Build: { build } '</a>'"
|
||||
},
|
||||
"github": "GitHub projekt",
|
||||
"insured": "Forsikret",
|
||||
"items": "Genstande",
|
||||
"join_discord": "Deltag i vores Discord",
|
||||
"labels": "Etiketter",
|
||||
"loading": "Indlæser…",
|
||||
"locations": "Lokationer",
|
||||
"maintenance": "Opretholdelse",
|
||||
"name": "Navn",
|
||||
"navigate": "Naviger",
|
||||
"password": "Adgangskode",
|
||||
"quantity": "Mængde",
|
||||
"read_docs": "Læs Docs",
|
||||
"return_home": "Vend hjem",
|
||||
"save": "Gem",
|
||||
"search": "Søg",
|
||||
"sign_out": "Log ud",
|
||||
"submit": "Indsend",
|
||||
"unknown": "Ukendt",
|
||||
"update": "Opdater",
|
||||
"updating": "Opdaterer",
|
||||
"value": "Værdi",
|
||||
"version": "Version: { version }",
|
||||
"welcome": "Velkommen, { username }"
|
||||
@@ -162,27 +251,49 @@
|
||||
"set_email": "Hvad er din E-Mail?",
|
||||
"set_name": "Hvad hedder du?",
|
||||
"set_password": "Opret din adgangskode",
|
||||
"tagline": "Følg, Organiser, og Håndter dine Ting."
|
||||
"tagline": "Følg, Organiser, og Håndter dine Ting.",
|
||||
"title": "Organiser og Tag dine ting",
|
||||
"toast": {
|
||||
"invalid_email": "Ugyldig e-mailadresse",
|
||||
"invalid_email_password": "Ugyldig e-mail eller adgangskode",
|
||||
"login_success": "Logget ind",
|
||||
"problem_registering": "Problem med at registrere bruger",
|
||||
"user_registered": "Bruger registreret"
|
||||
}
|
||||
},
|
||||
"items": {
|
||||
"add": "Tilføj",
|
||||
"advanced": "Avanceret",
|
||||
"archived": "Arkiveret",
|
||||
"asset_id": "Aktiv-id",
|
||||
"associated_with_multiple": "Dette aktiv-id er knyttet til flere varer",
|
||||
"attachment": "Vedhæftning",
|
||||
"attachments": "Vedhæftninger",
|
||||
"changes_persisted_immediately": "Ændringer af vedhæftede filer gemmes med det samme",
|
||||
"created_at": "Oprettet den",
|
||||
"custom_fields": "Brugerdefinerede felter",
|
||||
"delete_attachment_confirm": "Er du sikker på, at du vil slette denne vedhæftede fil?",
|
||||
"delete_item_confirm": "Er du sikker på, at du vil slette dette element?",
|
||||
"description": "Beskrivelse",
|
||||
"details": "Detaljer",
|
||||
"drag_and_drop": "Træk og slip filer her, eller klik for at vælge filer",
|
||||
"edit": {
|
||||
"edit_attachment_dialog": {
|
||||
"attachment_title": "Titel på vedhæftet fil",
|
||||
"attachment_type": "Vedhæftningstype",
|
||||
"primary_photo": "Primært foto",
|
||||
"primary_photo_sub": "Denne mulighed er kun tilgængelig for fotos. Kun ét foto kan være primært. Hvis du vælger denne mulighed, vil det aktuelle primære foto, hvis der er et, blive fravalgt.",
|
||||
"select_type": "Vælg en type",
|
||||
"title": "Rediger vedhæftet fil"
|
||||
}
|
||||
},
|
||||
"edit_details": "Rediger detaljer",
|
||||
"field_selector": "Feltvælger",
|
||||
"field_value": "Feltværdi",
|
||||
"first": "Første",
|
||||
"include_archive": "Medtag arkiverede elementer",
|
||||
"insured": "Forsikret",
|
||||
"invalid_asset_id": "Ugyldigt aktiv-ID",
|
||||
"last": "Sidst",
|
||||
"lifetime_warranty": "livstidsgaranti",
|
||||
"location": "Lokalitet",
|
||||
@@ -193,6 +304,7 @@
|
||||
"name": "Navn",
|
||||
"negate_labels": "Ophæv valgte etiketter",
|
||||
"next_page": "Næste side",
|
||||
"no_attachments": "Ingen vedhæftede filer fundet",
|
||||
"no_results": "Ingen elementer fundet",
|
||||
"notes": "Noter",
|
||||
"only_with_photo": "Kun elementer med foto",
|
||||
@@ -214,35 +326,77 @@
|
||||
"receipts": "Kvitteringer",
|
||||
"reset_search": "Nulstil Søgning",
|
||||
"results": "{ total } Wyniki",
|
||||
"select_field": "Vælg et felt",
|
||||
"serial_number": "Serienummer",
|
||||
"show_advanced_view_options": "vis avancerede indstillinger",
|
||||
"sold_at": "Solgt D.",
|
||||
"sold_details": "Salgs detaljer",
|
||||
"sold_price": "Solgt pris",
|
||||
"sold_to": "Sold til",
|
||||
"sync_child_locations": "Synkroniser placeringer af underordnede elementer",
|
||||
"tip_1": "Placerings- og etiketfiltre bruger betjeningen 'ELLER'. Hvis mere end én er valgt, kræves der kun én\n til et match.",
|
||||
"tip_2": "Søgninger med præfikset '#'' vil forespørge efter et aktiv-id (eksempel '#000-001')",
|
||||
"tip_3": "Feltfiltre bruger handlingen 'ELLER'. Hvis mere end én er valgt, kræves der kun én til en\n kamp.",
|
||||
"tips": "Tips",
|
||||
"tips_sub": "Søgetips",
|
||||
"toast": {
|
||||
"asset_not_found": "Aktivet blev ikke fundet",
|
||||
"attachment_deleted": "Vedhæftet fil slettet",
|
||||
"attachment_updated": "Vedhæftet fil opdateret",
|
||||
"attachment_uploaded": "Vedhæftet fil uploadet",
|
||||
"child_items_location_no_longer_synced": "Placeringen af underelementer vil ikke længere blive synkroniseret med dette element.",
|
||||
"child_items_location_synced": "Placeringerne af underordnede elementer er blevet synkroniseret med dette element",
|
||||
"child_location_desync": "Ændring af placering vil afsynkronisere den fra forælderens placering",
|
||||
"error_loading_parent_data": "Noget gik galt under indlæsning af overordnede data",
|
||||
"failed_adjust_quantity": "Kunne ikke justere mængden",
|
||||
"failed_delete_attachment": "Kunne ikke slette vedhæftet fil",
|
||||
"failed_delete_item": "Kunne ikke slette elementet",
|
||||
"failed_duplicate_item": "Kunne ikke duplikere elementet",
|
||||
"failed_load_asset": "Kunne ikke indlæse aktiv",
|
||||
"failed_load_item": "Kunne ikke indlæse elementet",
|
||||
"failed_load_items": "Kunne ikke indlæse elementer",
|
||||
"failed_save": "Kunne ikke gemme elementet",
|
||||
"failed_save_no_location": "Kunne ikke gemme elementet: ingen placering valgt",
|
||||
"failed_search_items": "Kunne ikke søge efter elementer",
|
||||
"failed_update_attachment": "Kunne ikke opdatere vedhæftet fil",
|
||||
"failed_upload_attachment": "Kunne ikke uploade vedhæftet fil",
|
||||
"item_deleted": "Element slettet",
|
||||
"item_saved": "Element gemt",
|
||||
"quantity_cannot_negative": "Mængden må ikke være negativ",
|
||||
"sync_child_location": "Den valgte forælder synkroniserer sine børns placeringer med sine egne. Placeringen er blevet opdateret."
|
||||
},
|
||||
"updated_at": "Opdateret d.",
|
||||
"warranty": "Garanti",
|
||||
"warranty_details": "Oplysninger om garanti",
|
||||
"warranty_expires": "Garantien udløber"
|
||||
},
|
||||
"labels": {
|
||||
"label_delete_confirm": "Er du sikker på, at du vil slette denne etiket? Denne handling kan ikke fortrydes.",
|
||||
"no_results": "Ingen etiketter fundet",
|
||||
"toast": {
|
||||
"failed_delete_label": "Etiketten kunne ikke slettes",
|
||||
"failed_load_label": "Etiketten kunne ikke indlæses",
|
||||
"failed_update_label": "Etiketten kunne ikke opdateres",
|
||||
"label_deleted": "Etiket slettet",
|
||||
"label_updated": "Etiket opdateret"
|
||||
},
|
||||
"update_label": "Opdater etiket"
|
||||
},
|
||||
"languages": {
|
||||
"ca": "Catalansk",
|
||||
"cs-CZ": "Tjekkisk",
|
||||
"de": "Tysk",
|
||||
"en": "Engelsk",
|
||||
"es": "Spansk",
|
||||
"fi-FI": "Finsk",
|
||||
"fr": "Fransk",
|
||||
"hu": "Ungarsk",
|
||||
"id-ID": "Indonesisk",
|
||||
"it": "Italiensk",
|
||||
"ja-JP": "Japansk",
|
||||
"ko-KR": "Koreansk",
|
||||
"lb-LU": "Luxembourgsk (Luxembourg)",
|
||||
"lt-LT": "Litauisk (Litauen)",
|
||||
"nb-NO": "Norsk",
|
||||
"nl": "Hollandsk",
|
||||
"pl": "Polsk",
|
||||
@@ -250,6 +404,7 @@
|
||||
"pt-PT": "Portugisisk (Portugal)",
|
||||
"ru": "Russisk",
|
||||
"sl": "Slovensk",
|
||||
"sq-AL": "Albansk",
|
||||
"sv": "Svensk",
|
||||
"ta-IN": "Tamilsk",
|
||||
"th-TH": "Thailandsk",
|
||||
@@ -266,7 +421,16 @@
|
||||
"locations": {
|
||||
"child_locations": "Underordnede placeringer",
|
||||
"collapse_tree": "Kollaps træ",
|
||||
"expand_tree": "Udvid træ",
|
||||
"location_items_delete_confirm": "Er du sikker på, at du vil slette denne placering og alle dens elementer? Denne handling kan ikke fortrydes.",
|
||||
"no_results": "Ingen placeringer fundet",
|
||||
"toast": {
|
||||
"failed_delete_location": "Kunne ikke slette placeringen",
|
||||
"failed_load_location": "Placeringen kunne ikke indlæses",
|
||||
"failed_update_location": "Kunne ikke opdatere placeringen",
|
||||
"location_deleted": "Placering slettet",
|
||||
"location_updated": "Placering opdateret"
|
||||
},
|
||||
"update_location": "Opdatér sted"
|
||||
},
|
||||
"maintenance": {
|
||||
@@ -322,7 +486,10 @@
|
||||
"currency_format": "Valuta format",
|
||||
"current_password": "Aktuel adgangskode",
|
||||
"delete_account": "Slet Konto",
|
||||
"delete_account_confirm": "Er du sikker på, at du vil slette din konto? Hvis du er det sidste medlem i din gruppe, vil alle dine data blive slettet. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_sub": "Slet din konto og alle dens tilknyttede data. Dette kan ikke laves om.",
|
||||
"delete_notifier_confirm": "Er du sikker på, at du vil slette denne underretter?",
|
||||
"display_legacy_header": "{ currentValue, select, true {Deaktiver Legacy Header} false {Aktiver Legacy Header} other {Ikke ramt}}",
|
||||
"enabled": "Aktiveret",
|
||||
"example": "Eksempel",
|
||||
"gen_invite": "Generer invitationslink",
|
||||
@@ -332,39 +499,95 @@
|
||||
"language": "Sprog",
|
||||
"new_password": "Ny Adgangskode",
|
||||
"no_notifiers": "Ingen notifikationer konfiguret",
|
||||
"no_override": "Ingen tilsidesættelse",
|
||||
"notifier_modal": "{ type, select, true {Rediger} false {Opret} other {Andet}} Meddeler",
|
||||
"notifiers": "Meddelere",
|
||||
"notifiers_sub": "Få notifikationer om kommende vedligeholdelsespåmindelser",
|
||||
"override_locale": "Tilsidesæt dato og valutasprog",
|
||||
"test": "Test",
|
||||
"theme_settings": "Temaindstillinger",
|
||||
"theme_settings_sub": "Temaindstillinger gemmes i din browsers lokale lager. Du kan til enhver tid ændre temaet. Hvis du har\n problemer med at indstille dit tema, kan du prøve at opdatere din browser.",
|
||||
"toast": {
|
||||
"account_deleted": "Din konto er blevet slettet.",
|
||||
"failed_change_password": "Kunne ikke ændre adgangskode.",
|
||||
"failed_create_notifier": "Kunne ikke oprette underretteren.",
|
||||
"failed_delete_account": "Kunne ikke slette din konto.",
|
||||
"failed_delete_notifier": "Kunne ikke slette underretteren.",
|
||||
"failed_get_currencies": "Kunne ikke hente valutaer",
|
||||
"failed_test_notifier": "Kunne ikke teste underretteren.",
|
||||
"failed_update_group": "Gruppen kunne ikke opdateres",
|
||||
"failed_update_notifier": "Kunne ikke opdatere underretteren.",
|
||||
"group_updated": "Gruppen er opdateret",
|
||||
"notifier_test_success": "Underretter-testen er gennemført.",
|
||||
"password_changed": "Adgangskoden er ændret."
|
||||
},
|
||||
"update_group": "Opdatér Gruppe",
|
||||
"update_language": "Opdater sprogfil",
|
||||
"url": "URL",
|
||||
"user_profile": "Brugerprofil",
|
||||
"user_profile_sub": "Inviter brugere, og administrer din konto."
|
||||
},
|
||||
"reports": {
|
||||
"label_generator": {
|
||||
"asset_end": "Aktivets slut",
|
||||
"asset_start": "Aktivets start",
|
||||
"base_url": "Base URL",
|
||||
"bordered_labels": "Etiketter med kant",
|
||||
"generate_page": "Generer side",
|
||||
"input_placeholder": "Skriv her",
|
||||
"instruction_1": "Homebox Label Generator er et værktøj, der hjælper dig med at udskrive etiketter til dit Homebox-lager. Disse er beregnet til\nat være etiketter, der kan udskrives på forhånd, så du kan udskrive mange etiketter og have dem klar til påsætning.",
|
||||
"instruction_2": "Disse etiketter fungerer derfor ved at udskrive en URL, QR-kode og AssetID-oplysninger på en etiket. Hvis du har deaktiveret\nAssetID'er i dine Homebox-indstillinger, kan du stadig bruge dette værktøj, men AssetID'erne vil ikke referere til nogen varer.",
|
||||
"instruction_3": "Denne funktion er i de tidlige udviklingsfaser og kan ændres i fremtidige udgivelser. Hvis du har feedback, bedes\ndu give den i '<a href=\"https://github.com/sysadminsmedia/homebox/discussions/53\">'GitHub-diskussionen'</a>'",
|
||||
"label_height": "Etikethøjde",
|
||||
"label_width": "Etiketbredde",
|
||||
"measure_type": "Målingstype",
|
||||
"page_bottom_padding": "Sidebundspolstring",
|
||||
"page_height": "Sidehøjde",
|
||||
"page_left_padding": "Venstre sidepolstring",
|
||||
"page_right_padding": "Højre sidepolstring",
|
||||
"page_top_padding": "Sidetoppolstring",
|
||||
"page_width": "Sidebredde",
|
||||
"qr_code_example": "Eksempel på QR-kode",
|
||||
"tip_1": "Standardindstillingerne her er konfigureret for\n'<a href=\"https://www.avery.com/templates/5260\">'Avery 5260 etiketark'</a>'. Hvis du bruger et andet ark,\nskal du justere indstillingerne, så de passer til dit ark.",
|
||||
"tip_2": "Hvis du tilpasser dit ark, er dimensionerne i tommer. Da jeg byggede 5260-arket, opdagede jeg, at de\ndimensioner, der blev brugt i deres skabelon, ikke matchede det, der var nødvendigt for at udskrive i felterne.\n'<b>'Vær forberedt på nogle forsøg og fejl.'</b>'",
|
||||
"tip_3": "Ved printning sørg for at:\n'<ol><li>'Indstil margenerne til 0 eller Ingen'</li><li>'Indstil skaleringen til 100%'</li><li>'Deaktiver dobbeltsidet udskrivning'</li><li>'Udskriv en testside, før du udskriver flere sider'</li></ol>'",
|
||||
"tips": "Tips",
|
||||
"title": "Etiketgenerator",
|
||||
"toast": {
|
||||
"page_too_small_card": "Sidestørrelsen er for lille til kortstørrelsen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"error": "Der skete en fejl under skanningen",
|
||||
"invalid_url": "Ugyldig stregkode-URL",
|
||||
"no_sources": "Ingen videokilder er tilgængelig",
|
||||
"permission_denied": "Kameratilladelse nægtet, Tillad venligst adgang til kameraet i dine browserindstillinger",
|
||||
"select_video_source": "Vælg en videokilde",
|
||||
"title": "Skanner",
|
||||
"unsupported": "Media Stream API understøttes ikke uden HTTPS"
|
||||
},
|
||||
"tools": {
|
||||
"actions": "Handlinger på lagerbeholdning",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Opret manglende miniaturebilleder",
|
||||
"create_missing_thumbnails_button": "Opret miniaturebilleder",
|
||||
"create_missing_thumbnails_confirm": "Er du sikker på, at du vil oprette manglende miniaturebilleder? Dette kan tage et stykke tid og kan ikke sættes på pause.",
|
||||
"create_missing_thumbnails_sub": "Opretter miniaturebilleder for alle vedhæftede filer, der understøttes af den aktuelle konfiguration. Dette er nyttigt for vedhæftede filer, der blev uploadet før v0.20.0-udgivelsen af Homebox. Dette overskriver ikke eksisterende miniaturebilleder, men opretter kun nye for vedhæftede filer, der ikke har et miniaturebillede. Bemærk, at miniaturebillederne oprettes i baggrunden og kan tage et stykke tid at fuldføre.",
|
||||
"ensure_ids": "Sørg for aktiv-id'er",
|
||||
"ensure_ids_button": "Sørg for aktiv-id'er",
|
||||
"ensure_ids_confirm": "Er du sikker på, at du vil sikre dig, at alle aktiver har et ID? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"ensure_ids_sub": "Sikrer, at alle varer på lageret har et gyldigt asset_id felt. Dette gøres ved at finde det højeste aktuelle aktiv_id felt i databasen og anvende den næste værdi på hvert element, der har et ikke sat aktiv_id felt. Dette gøres i rækkefølge efter feltet opret_den.",
|
||||
"ensure_import_refs": "Sørg for importreferencer",
|
||||
"ensure_import_refs_button": "Sørg for importreferencer",
|
||||
"ensure_import_refs_sub": "Sikrer, at alle varer på lageret har et gyldigt import_ref felt. Dette gøres ved tilfældigt at generere en streng på 8 tegn for hvert element, der har et uindstillet import_ref felt.",
|
||||
"set_primary_photo": "Indstil primært foto",
|
||||
"set_primary_photo_button": "Indstil primært foto",
|
||||
"set_primary_photo_confirm": "Er du sikker på, at du vil indstille primære billeder? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"set_primary_photo_sub": "I version v0.10.0 af Homebox blev det primære billedfelt tilføjet til vedhæftede filer af typen foto. Denne handling indstiller det primære billedfelt til det første billede i matrixen for vedhæftede filer i databasen, hvis det ikke allerede er angivet. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/pull/576\">'Se GitHub PR #576'</a>'",
|
||||
"zero_datetimes": "Nul Vare Dato Tider",
|
||||
"zero_datetimes_button": "Nul Varedato Tider",
|
||||
"zero_datetimes_confirm": "Er du sikker på, at du vil nulstille alle dato- og klokkeslætsværdier? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"zero_datetimes_sub": "Nulstiller klokkeslætsværdien for alle dato- og klokkeslætsfelter i lageret til begyndelsen af datoen. Dette er for at rette en fejl, der blev introduceret tidligt i udviklingen af webstedet, der forårsagede, at tidsværdien blev gemt med tiden, hvilket forårsagede problemer med datofelter, der viste nøjagtige værdier. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/issues/236\" target=\"_blank\">'Se Github-udgave #236 for flere detaljer.'</a>'"
|
||||
},
|
||||
"actions_sub": "Anvend flere handlinger på din beholdning på én gang. Det er uigenkaldelige handlinger. '<b>'Vær forsigtig.'</b>'",
|
||||
@@ -375,6 +598,7 @@
|
||||
"export_sub": "Eksporterer standard CSV-formatet til Homebox. Dette vil eksportere alle varer i dit lager.",
|
||||
"import": "Importeret beholdning",
|
||||
"import_button": "Importer beholdning",
|
||||
"import_ref_confirm": "Er du sikker på, at du vil sikre dig, at alle aktiver har en import_ref? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"import_sub": "Importerer standard CSV-formatet til Homebox. Uden en '<code>'HB.import_ref'</code>'-kolonne vil dette '<b>'ikke'</b>' overskrive eksisterende genstande i dit lager, kun tilføje nye genstande. Rækker med kolonnen \"<code>HB.import_ref\"</code> flettes ind i eksisterende elementer med samme import_ref, hvis der findes en."
|
||||
},
|
||||
"import_export_sub": "Importér og eksporter din lagerbeholdning til og fra en CSV-fil. Dette er nyttigt til at migrere dit lager til en ny forekomst af Homebox.",
|
||||
@@ -387,6 +611,14 @@
|
||||
"bill_of_materials_button": "Generer stykliste",
|
||||
"bill_of_materials_sub": "Genererer en CSV-fil (kommaseparerede værdier), der kan importeres til et regnearksprogram. Dette er en oversigt over din beholdning med grundlæggende vare- og prisoplysninger."
|
||||
},
|
||||
"reports_sub": "Generer forskellige rapporter for dit lager."
|
||||
"reports_sub": "Generer forskellige rapporter for dit lager.",
|
||||
"toast": {
|
||||
"asset_success": "Aktiverne i { results } er blevet opdateret.",
|
||||
"failed_create_missing_thumbnails": "Kunne ikke oprette manglende miniaturebilleder.",
|
||||
"failed_ensure_ids": "Kunne ikke sikre aktiv-ID'er.",
|
||||
"failed_ensure_import_refs": "Kunne ikke sikre importreferencer.",
|
||||
"failed_set_primary_photos": "Kunne ikke indstille primære billeder.",
|
||||
"failed_zero_datetimes": "Dato- og klokkeslætsværdier kunne ikke nulstilles."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,11 @@
|
||||
"new_version_available_link": "Cliquez ici pour consulter les notes de version"
|
||||
}
|
||||
},
|
||||
"color_selector": {
|
||||
"color": "Couleur",
|
||||
"no_color_selected": "Aucune couleur sélectionnée",
|
||||
"randomize": "Couleur aléatoire"
|
||||
},
|
||||
"form": {
|
||||
"password": {
|
||||
"toggle_show": "Activer/désactiver l'affichage du mot de passe"
|
||||
@@ -129,7 +134,8 @@
|
||||
"headers": "En-têtes",
|
||||
"page": "Page",
|
||||
"rows_per_page": "Lignes par page",
|
||||
"table_settings": "Paramètres du Tableau"
|
||||
"table_settings": "Paramètres du Tableau",
|
||||
"view_item": "Afficher l'élément"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -561,6 +567,10 @@
|
||||
"tools": {
|
||||
"actions": "Actions d’inventaire",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Crée les miniatures manquantes",
|
||||
"create_missing_thumbnails_button": "Crée les miniatures",
|
||||
"create_missing_thumbnails_confirm": "Êtes-vous sûr de vouloir créer les vignettes manquantes ? Cette opération peut prendre un certain temps et ne peut pas être interrompue.",
|
||||
"create_missing_thumbnails_sub": "Crée des miniatures pour toutes les pièces jointes prises en charge par la configuration actuelle. Ceci est utile pour les pièces jointes importées avant la version 0.20.0 de Homebox. Cette opération n'écrase pas les miniatures existantes, mais crée de nouvelles miniatures pour les pièces jointes sans miniature. Veuillez noter que la création des miniatures s'effectue en arrière-plan et peut prendre un certain temps.",
|
||||
"ensure_ids": "Vérifier les ID de ressources",
|
||||
"ensure_ids_button": "Vérifier les ID de ressources",
|
||||
"ensure_ids_confirm": "Êtes-vous certain de vous assurer que toutes les ressources ont une ID ? Cela peut prendre du temps et est irréversible.",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"item_photo": "Objektfoto",
|
||||
"item_quantity": "Objektantall",
|
||||
"parent_item": "Overordnet enhet",
|
||||
"rotate_photo": "Roter bile",
|
||||
"rotate_photo": "Roter bilde",
|
||||
"set_as_primary_photo": "Sett som { isPrimary, select, true {non-} false {} other {}}primary photo",
|
||||
"title": "Opprett objekt",
|
||||
"toast": {
|
||||
@@ -110,7 +110,7 @@
|
||||
"failed_load_parent": "Klarte ikke å laster overordnet enhet, vennligst velg en manuelt",
|
||||
"no_canvas_support": "Nettleseren din støtter ikke canvas-operasjoner",
|
||||
"please_select_location": "Vennligst velg en lokasjon.",
|
||||
"rotate_failed": "Fikk ikke å rotere bildet: { error }",
|
||||
"rotate_failed": "Fikk ikke til å rotere bildet: { error }",
|
||||
"rotate_process_failed": "Fikk ikke prosessert rotert bilde",
|
||||
"some_photos_failed": "{count, plural, =0 {Ingen bilder å laste opp.} =1 {1 fikk ikke lastet opp bilde.} other {Noen bilder ble ikke lastet opp.}}",
|
||||
"upload_failed": "Fikk ikke lastet opp bildet: { photoName }",
|
||||
@@ -121,9 +121,9 @@
|
||||
"uploaded": "Bilde lastet opp"
|
||||
},
|
||||
"selector": {
|
||||
"no_results": "Ikke funnet resultat",
|
||||
"placeholder": "Velg...",
|
||||
"search_placeholder": "Skriv for å søke..."
|
||||
"no_results": "Ingen resultat funnet",
|
||||
"placeholder": "Velg…",
|
||||
"search_placeholder": "Skriv for å søke…"
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
@@ -149,7 +149,7 @@
|
||||
"title": "Opprett merkelapp",
|
||||
"toast": {
|
||||
"already_creating": "Opprettelse av etikett pågår allerede",
|
||||
"create_failed": "Fikk ikke opprettet etikett",
|
||||
"create_failed": "Kunne ikke opprette etikett",
|
||||
"create_success": "Etikett opprettet",
|
||||
"label_name_too_long": "Etikettnavn kan ikke overstige 50 tegn"
|
||||
}
|
||||
@@ -195,7 +195,7 @@
|
||||
"create_subitem": "Opprett underenhet",
|
||||
"created": "Opprettet",
|
||||
"delete": "Slett",
|
||||
"delete_confirm": "Vil du virkelig slette denne enheten? ",
|
||||
"delete_confirm": "Er du sikker på at du ønsker å slette denne enheten? ",
|
||||
"demo_instance": "Dette er et demomiljø",
|
||||
"details": "Detaljer",
|
||||
"duplicate": "Dupliser",
|
||||
@@ -368,7 +368,7 @@
|
||||
"updated_at": "Oppdatert den",
|
||||
"warranty": "Garanti",
|
||||
"warranty_details": "Garantidetaljer",
|
||||
"warranty_expires": "Garanti upløper"
|
||||
"warranty_expires": "Garanti utløper"
|
||||
},
|
||||
"labels": {
|
||||
"label_delete_confirm": "Er du sikker på at du vil slette denne etiketten? Denne handlingen kan ikke angres.",
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
"create_missing_thumbnails_button": "Vytvoriť náhľady",
|
||||
"create_missing_thumbnails_confirm": "Naozaj chcete vytvoriť chýbajúce náhľady? Môže to chvíľku trvať a akciu nie je možné pozastaviť.",
|
||||
"create_missing_thumbnails_sub": "Vytvorí miniatúry pre všetky prílohy, ktoré sú podporované aktuálnou konfiguráciou. Toto je užitočné pre prílohy, ktoré boli nahrané pred vydaním Homeboxu v0.20.0. Toto neprepíše existujúce miniatúry, vytvorí nové iba pre prílohy, ktoré nemajú miniatúru. Upozorňujeme, že miniatúry sa vytvárajú na pozadí a ich vytvorenie môže chvíľu trvať.",
|
||||
"ensure_ids": "Zabezpečenie identifikátorov aktív",
|
||||
"ensure_ids": "Zabezpečenie identifikátorov položiek",
|
||||
"ensure_ids_button": "Zabezpečenie identifikátorov aktív",
|
||||
"ensure_ids_confirm": "Naozaj chcete zabezpečiť, aby všetky prvky mali ID? Môže to chvíľu trvať a nedá sa to vrátiť späť.",
|
||||
"ensure_ids_sub": "Zabezpečí, aby všetky položky vo vašom inventári mali platné pole asset_id. To sa dosiahne tak, že sa v databáze zistí najvyššie aktuálne pole asset_id a na každú položku, ktorá nemá nastavené pole asset_id sa použije ďalšia hodnota. Toto sa vykonáva v poradí podľa poľa created_at.",
|
||||
@@ -604,10 +604,10 @@
|
||||
"import_export_sub": "Importujte a exportujte svoj inventár do a zo súboru CSV. To je užitočné pri migrácii inventára do novej inštancie Homeboxu.",
|
||||
"reports": "Správy",
|
||||
"reports_set": {
|
||||
"asset_labels": "Štítky ID diela",
|
||||
"asset_labels": "Identifikačné štítky položiek",
|
||||
"asset_labels_button": "Generátor štítkov",
|
||||
"asset_labels_sub": "Generuje tlačiteľné PDF štítkov pre rad Asset ID. Tieto nie sú špecifické pre váš inventár, takže štítky si môžete vytlačiť vopred a aplikovať ich na váš inventár, keď ich dostanete.",
|
||||
"bill_of_materials": "Faktúra za materiály",
|
||||
"bill_of_materials": "Súpis položiek",
|
||||
"bill_of_materials_button": "Generovať kusovník",
|
||||
"bill_of_materials_sub": "Vygeneruje súbor CSV (Comma Separated Values), ktorý možno importovať do tabuľkového procesora. Toto je súhrn vášho inventára so základnými informáciami o položkách a cenách."
|
||||
},
|
||||
|
||||
@@ -571,6 +571,9 @@
|
||||
"actions": "Dejanja inventarja",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Ustvari manjkajoče predoglede",
|
||||
"create_missing_thumbnails_button": "Ustvari Predoglede",
|
||||
"create_missing_thumbnails_confirm": "Ali ste prepričani, da želite ustvariti manjkajoče predoglede? To lahko traja nekaj časa in ni možno ustaviti.",
|
||||
"create_missing_thumbnails_sub": "Ustvari predoglede za vse priloge, ki jih omogoča trenutna konfiguracija. To je uporabno za priloge, ki so bile naložene pred Homebox izdajo v0.20.0. To ne bo prepisalo obstoječih predogledov, ampak samo izdelalo nove za priloge ki še nimajo predogledov. Upoštevajte da so predogledi izdelani v ozadju in da to lahko traja nekaj časa.",
|
||||
"ensure_ids": "Zagotovi ID-je sredstev",
|
||||
"ensure_ids_button": "Zagotovi ID-je sredstev",
|
||||
"ensure_ids_confirm": "Ali ste prepričani, da želite zagotoviti, da imajo vsi predmeti ID? To lahko traja nekaj časa in ni možno razveljaviti.",
|
||||
@@ -611,6 +614,7 @@
|
||||
"reports_sub": "Ustvari različna poročila za svoj inventar.",
|
||||
"toast": {
|
||||
"asset_success": "{ results } predmetov je bilo posodobljenih.",
|
||||
"failed_create_missing_thumbnails": "Izdelava manjkajočih predogledov ni uspela.",
|
||||
"failed_ensure_ids": "Zagotavljanje IDjev predmetov ni uspelo.",
|
||||
"failed_ensure_import_refs": "Zagotavljanje import_ref ni uspelo.",
|
||||
"failed_set_primary_photos": "Nastavljanje primarnih fotografij ni uspelo.",
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
if (preferences.value.showEmpty) {
|
||||
return true;
|
||||
}
|
||||
return validDate(item.value?.warrantyExpires);
|
||||
return item.value?.lifetimeWarranty || validDate(item.value?.warrantyExpires);
|
||||
});
|
||||
|
||||
const warrantyDetails = computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user