Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,374 @@
package service
import (
"embed"
"encoding/json"
"errors"
"net/http"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"google.golang.org/grpc/metadata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/ast"
"github.com/qsfera/server/pkg/kql"
"github.com/qsfera/server/pkg/l10n"
ehmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/eventhistory/v0"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
)
var (
//go:embed l10n/locale
_localeFS embed.FS
// subfolder where the translation files are stored
_localeSubPath = "l10n/locale"
// domain of the activitylog service (transifex)
_domain = "activitylog"
)
// ServeHTTP implements the http.Handler interface.
func (s *ActivitylogService) ServeHTTP(w http.ResponseWriter, r *http.Request) {
s.mux.ServeHTTP(w, r)
}
// HandleGetItemActivities handles the request to get the activities of an item.
func (s *ActivitylogService) HandleGetItemActivities(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, r.Header.Get(revactx.TokenHeader))
activeUser, ok := revactx.ContextGetUser(ctx)
if !ok {
w.WriteHeader(http.StatusUnauthorized)
return
}
gwc, err := s.gws.Next()
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
rid, limit, rawActivityAccepted, activityAccepted, sort, err := s.getFilters(r.URL.Query().Get("kql"))
if err != nil {
s.log.Info().Str("query", r.URL.Query().Get("kql")).Err(err).Msg("error getting filters")
_, _ = w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusBadRequest)
return
}
info, err := utils.GetResourceByID(ctx, rid, gwc)
if err != nil {
w.WriteHeader(http.StatusForbidden)
return
}
// you need ListGrants to see activities
if !info.GetPermissionSet().GetListGrants() {
w.WriteHeader(http.StatusForbidden)
return
}
raw, err := s.Activities(rid)
if err != nil {
s.log.Error().Err(err).Msg("error getting activities")
w.WriteHeader(http.StatusInternalServerError)
return
}
ids := make([]string, 0, len(raw))
toDelete := make(map[string]struct{}, len(raw))
for _, a := range raw {
if !rawActivityAccepted(a) {
continue
}
ids = append(ids, a.EventID)
toDelete[a.EventID] = struct{}{}
}
evRes, err := s.evHistory.GetEvents(r.Context(), &ehsvc.GetEventsRequest{Ids: ids})
if err != nil {
s.log.Error().Err(err).Msg("error getting events")
w.WriteHeader(http.StatusInternalServerError)
return
}
evs := evRes.GetEvents()
sort(evs)
resp := GetActivitiesResponse{Activities: make([]libregraph.Activity, 0, len(evRes.GetEvents()))}
for _, e := range evs {
delete(toDelete, e.GetId())
if limit > 0 && limit <= len(resp.Activities) {
continue
}
if !activityAccepted(e) {
continue
}
var (
message string
ts time.Time
vars map[string]any
)
loc := l10n.MustGetUserLocale(r.Context(), activeUser.GetId().GetOpaqueId(), r.Header.Get(l10n.HeaderAcceptLanguage), s.valService)
t := l10n.NewTranslatorFromCommonConfig(s.cfg.DefaultLanguage, _domain, s.cfg.TranslationPath, _localeFS, _localeSubPath)
switch ev := s.unwrapEvent(e).(type) {
case nil:
// error already logged in unwrapEvent
continue
case events.UploadReady:
message = MessageResourceCreated
if ev.IsVersion {
message = MessageResourceUpdated
}
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithResource(ev.FileRef, false, ""), WithUser(nil, ev.ExecutingUser, ev.ImpersonatingUser))
case events.FileTouched:
message = MessageResourceCreated
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithResource(ev.Ref, false, ""), WithUser(ev.Executant, nil, ev.ImpersonatingUser))
case events.FileDownloaded:
message = MessageResourceDownloaded
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithResource(ev.Ref, false, ""), WithUser(ev.Executant, nil, ev.ImpersonatingUser), WithVar("token", "", ev.ImpersonatingUser.GetId().GetOpaqueId()))
case events.ContainerCreated:
message = MessageResourceCreated
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithResource(ev.Ref, false, ""), WithUser(ev.Executant, nil, ev.ImpersonatingUser))
case events.ItemTrashed:
message = MessageResourceTrashed
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithTrashedResource(ev.Ref, ev.ID), WithUser(ev.Executant, nil, ev.ImpersonatingUser))
case events.ItemMoved:
switch isRename(ev.OldReference, ev.Ref) {
case true:
message = MessageResourceRenamed
vars, err = s.GetVars(ctx, WithResource(ev.Ref, false, ""), WithOldResource(ev.OldReference), WithUser(ev.Executant, nil, ev.ImpersonatingUser))
case false:
message = MessageResourceMoved
vars, err = s.GetVars(ctx, WithResource(ev.Ref, false, ""), WithUser(ev.Executant, nil, ev.ImpersonatingUser))
}
ts = utils.TSToTime(ev.Timestamp)
case events.ShareCreated:
message = MessageShareCreated
ts = utils.TSToTime(ev.CTime)
vars, err = s.GetVars(ctx,
WithResource(toRef(ev.ItemID), false, ev.ResourceName),
WithUser(ev.Executant, nil, nil),
WithSharee(ev.GranteeUserID, ev.GranteeGroupID))
case events.ShareUpdated:
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() == ev.ItemID.GetSpaceId() {
continue
}
message = MessageShareUpdated
ts = utils.TSToTime(ev.MTime)
vars, err = s.GetVars(ctx,
WithResource(toRef(ev.ItemID), false, ev.ResourceName),
WithUser(ev.Executant, nil, nil),
WithTranslation(&t, loc, "field", ev.UpdateMask))
case events.ShareRemoved:
message = MessageShareDeleted
ts = ev.Timestamp
vars, err = s.GetVars(ctx,
WithResource(toRef(ev.ItemID), false, ev.ResourceName),
WithUser(ev.Executant, nil, nil),
WithSharee(ev.GranteeUserID, ev.GranteeGroupID))
case events.LinkCreated:
message = MessageLinkCreated
ts = utils.TSToTime(ev.CTime)
vars, err = s.GetVars(ctx,
WithResource(toRef(ev.ItemID), false, ev.ResourceName),
WithUser(ev.Executant, nil, nil))
case events.LinkUpdated:
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() == ev.ItemID.GetSpaceId() {
continue
}
message = MessageLinkUpdated
ts = utils.TSToTime(ev.MTime)
vars, err = s.GetVars(ctx,
WithVar("resource", storagespace.FormatResourceID(ev.ItemID), ev.ResourceName),
WithUser(ev.Executant, nil, nil),
WithTranslation(&t, loc, "field", []string{ev.FieldUpdated}),
WithVar("token", ev.ItemID.GetOpaqueId(), ev.DisplayName))
case events.LinkRemoved:
message = MessageLinkDeleted
ts = utils.TSToTime(ev.Timestamp)
vars, err = s.GetVars(ctx, WithResource(toRef(ev.ItemID), false, ""), WithUser(ev.Executant, nil, nil))
case events.SpaceShared:
message = MessageSpaceShared
ts = ev.Timestamp
vars, err = s.GetVars(ctx, WithSpace(ev.ID), WithUser(ev.Executant, nil, nil), WithSharee(ev.GranteeUserID, ev.GranteeGroupID))
case events.SpaceUnshared:
message = MessageSpaceUnshared
ts = ev.Timestamp
vars, err = s.GetVars(ctx, WithSpace(ev.ID), WithUser(ev.Executant, nil, nil), WithSharee(ev.GranteeUserID, ev.GranteeGroupID))
}
if err != nil {
s.log.Error().Err(err).Msg("error getting response data")
continue
}
resp.Activities = append(resp.Activities, NewActivity(t.Translate(message, loc), ts, e.GetId(), vars))
}
// delete activities in separate go routine
if len(toDelete) > 0 {
go func() {
err := s.RemoveActivities(rid, toDelete)
if err != nil {
s.log.Error().Err(err).Msg("error removing activities")
}
}()
}
b, err := json.Marshal(resp)
if err != nil {
s.log.Error().Err(err).Msg("error marshalling activities")
w.WriteHeader(http.StatusInternalServerError)
return
}
if _, err := w.Write(b); err != nil {
s.log.Error().Err(err).Msg("error writing response")
w.WriteHeader(http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
func (s *ActivitylogService) unwrapEvent(e *ehmsg.Event) any {
etype, ok := s.registeredEvents[e.GetType()]
if !ok {
s.log.Error().Str("eventid", e.GetId()).Str("eventtype", e.GetType()).Msg("event not registered")
return nil
}
einterface, err := etype.Unmarshal(e.GetEvent())
if err != nil {
s.log.Error().Str("eventid", e.GetId()).Str("eventtype", e.GetType()).Msg("failed to umarshal event")
return nil
}
return einterface
}
func (s *ActivitylogService) getFilters(query string) (*provider.ResourceId, int, func(RawActivity) bool, func(*ehmsg.Event) bool, func([]*ehmsg.Event), error) {
qast, err := kql.Builder{}.Build(query)
if err != nil {
return nil, 0, nil, nil, nil, err
}
prefilters := make([]func(RawActivity) bool, 0)
postfilters := make([]func(*ehmsg.Event) bool, 0)
sortby := func(_ []*ehmsg.Event) {}
var (
itemID string
limit int
)
for _, n := range qast.Nodes {
switch v := n.(type) {
case *ast.StringNode:
switch strings.ToLower(v.Key) {
case "itemid":
itemID = v.Value
case "depth":
depth, err := strconv.Atoi(v.Value)
if err != nil {
return nil, limit, nil, nil, sortby, err
}
if depth == -1 {
break
}
prefilters = append(prefilters, func(a RawActivity) bool {
return a.Depth <= depth
})
case "limit":
l, err := strconv.Atoi(v.Value)
if err != nil {
return nil, limit, nil, nil, sortby, err
}
limit = l
case "sort":
switch v.Value {
case "asc":
// nothing to do - already ascending
case "desc":
sortby = func(activities []*ehmsg.Event) {
slices.Reverse(activities)
}
}
}
case *ast.DateTimeNode:
switch v.Operator.Value {
case "<", "<=":
prefilters = append(prefilters, func(a RawActivity) bool {
return a.Timestamp.Before(v.Value)
})
case ">", ">=":
prefilters = append(prefilters, func(a RawActivity) bool {
return a.Timestamp.After(v.Value)
})
}
case *ast.OperatorNode:
if v.Value != "AND" {
return nil, limit, nil, nil, sortby, errors.New("only AND operator is supported")
}
}
}
rid, err := storagespace.ParseID(itemID)
if err != nil {
return nil, limit, nil, nil, sortby, err
}
if rid.GetOpaqueId() == "" {
// space root requested - fix format
rid.OpaqueId = rid.GetSpaceId()
}
pref := func(a RawActivity) bool {
for _, f := range prefilters {
if !f(a) {
return false
}
}
return true
}
postf := func(e *ehmsg.Event) bool {
for _, f := range postfilters {
if !f(e) {
return false
}
}
return true
}
return &rid, limit, pref, postf, sortby, nil
}
// returns true if this is just a rename
func isRename(o, n *provider.Reference) bool {
// if resourceids are different we assume it is a move
if !utils.ResourceIDEqual(o.GetResourceId(), n.GetResourceId()) {
return false
}
return filepath.Base(o.GetPath()) != filepath.Base(n.GetPath())
}
@@ -0,0 +1,11 @@
[main]
host = https://www.transifex.com
[o:qsfera-eu:p:qsfera-eu:r:qsfera-activitylog]
file_filter = locale/<lang>/LC_MESSAGES/activitylog.po
minimum_perc = 75
resource_name = qsfera-activitylog
source_file = activitylog.pot
source_lang = en
type = PO
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Ivan Fustero, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Ivan Fustero, 2025\n"
"Language-Team: Catalan (https://app.transifex.com/qsfera-eu/teams/204053/ca/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ca\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "descripció"
#: pkg/service/response.go:43
msgid "display name"
msgstr "nom a mostrar"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "data de venciment"
#: pkg/service/response.go:41
msgid "password"
msgstr "contrasenya"
#: pkg/service/response.go:40
msgid "permission"
msgstr "permís"
#: pkg/service/response.go:39
msgid "some field"
msgstr "algun camp"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} s'ha baixat a través de l'enllaç públic {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} ha afegit {resource} a {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} ha afegit {resource} a {folder}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} ha suprimit {resource} de {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} ha mogut {resource} cap a {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr " {user} ha suprimit l'enllaç de {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} ha suprimit {sharee} de {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} ha suprimit {sharee} de {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} ha renombrat {oldResource} a {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} ha compartit {resource} via enllaç"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} ha compartit {resource} amb {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} ha actualitzat {field} per l'enllaç {token} a {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} ha actualitzat {field} per al {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} ha actualitzat {resource} a {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jörn Friedrich Dreyer <jfd@butonic.de>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jörn Friedrich Dreyer <jfd@butonic.de>, 2025\n"
"Language-Team: German (https://app.transifex.com/qsfera-eu/teams/204053/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "Beschreibung"
#: pkg/service/response.go:43
msgid "display name"
msgstr "Anzeigename"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "Ablaufdatum"
#: pkg/service/response.go:41
msgid "password"
msgstr "Passwort"
#: pkg/service/response.go:40
msgid "permission"
msgstr "Berechtigung"
#: pkg/service/response.go:39
msgid "some field"
msgstr "ein Bereich"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} wurde via öffentlichem Link {token} heruntergeladen"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} hat {resource} zu {folder} hinzugefügt"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} hat {sharee} als Mitglied zu {space} hinzugefügt"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} hat {resource} von {folder} gelöscht"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} hat {resource} nach {folder} verschoben"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} hat den Link zu {resource} entfernt"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} hat {sharee} von {resource} entfernt"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} hat {sharee} von {space} entfernt"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} hat {oldResource} zu {resource} umbenannt"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} hat {resource} via Link geteilt"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} hat {resource} mit {sharee} geteilt"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} hat {field} für einen Link {token} bei {resource} aktualisiert"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} hat {field} für die {resource} aktualisiert"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} hat {resource} im {folder} aktualisiert"
@@ -0,0 +1,110 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Efstathios Iosifidis <eiosifidis@gmail.com>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-23 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Efstathios Iosifidis <eiosifidis@gmail.com>, 2026\n"
"Language-Team: Greek (https://app.transifex.com/qsfera-eu/teams/204053/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "περιγραφή"
#: pkg/service/response.go:43
msgid "display name"
msgstr "όνομα εμφάνισης"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "ημερομηνία λήξης"
#: pkg/service/response.go:41
msgid "password"
msgstr "κωδικός πρόσβασης"
#: pkg/service/response.go:40
msgid "permission"
msgstr "δικαίωμα"
#: pkg/service/response.go:39
msgid "some field"
msgstr "κάποιο πεδίο"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "ο πόρος {resource} λήφθηκε μέσω του δημόσιου συνδέσμου {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "ο χρήστης {user} πρόσθεσε το στοιχείο {resource} στον φάκελο {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr ""
"ο χρήστης {user} πρόσθεσε τον χρήστη {sharee} ως μέλος του χώρου {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr ""
"ο χρήστης {user} διέγραψε το στοιχείο {resource} από τον φάκελο {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "ο χρήστης {user} μετέφερε το στοιχείο {resource} στον φάκελο {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "ο χρήστης {user} αφαίρεσε τον σύνδεσμο για το στοιχείο {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr ""
"ο χρήστης {user} αφαίρεσε τον χρήστη {sharee} από το στοιχείο {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "ο χρήστης {user} αφαίρεσε τον χρήστη {sharee} από τον χώρο {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "ο χρήστης {user} μετονόμασε το {oldResource} σε {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "ο χρήστης {user} διαμοιράστηκε το στοιχείο {resource} μέσω συνδέσμου"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr ""
"ο χρήστης {user} διαμοιράστηκε το στοιχείο {resource} με τον χρήστη {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr ""
"ο χρήστης {user} ενημέρωσε το πεδίο {field} για έναν σύνδεσμο {token} στο "
"στοιχείο {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr ""
"ο χρήστης {user} ενημέρωσε το πεδίο {field} για το στοιχείο {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr ""
"ο χρήστης {user} ενημέρωσε το στοιχείο {resource} στον φάκελο {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Elías Martín, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Elías Martín, 2025\n"
"Language-Team: Spanish (https://app.transifex.com/qsfera-eu/teams/204053/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "descripción"
#: pkg/service/response.go:43
msgid "display name"
msgstr "nombre mostrado"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "fecha de expiración"
#: pkg/service/response.go:41
msgid "password"
msgstr "contraseña"
#: pkg/service/response.go:40
msgid "permission"
msgstr "permiso"
#: pkg/service/response.go:39
msgid "some field"
msgstr "algún campo"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} fué descargado vía link público {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} añadió {resource} a {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} añadió {sharee} como miembro de {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} eliminó {resource} de {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} movió {resource} a {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} eliminó el link a {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} eliminó {sharee} de {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} eliminó {sharee} de {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} renombró {oldResource} a {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} compartió {resource} vía enlace"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} compartió {resource} con {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} actualizó {field} para el link {token} en {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} actualizó {field} para el {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} actualizó {resource} en {folder}"
@@ -0,0 +1,103 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jiri Grönroos <jiri.gronroos@iki.fi>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-08 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2025\n"
"Language-Team: Finnish (https://app.transifex.com/qsfera-eu/teams/204053/fi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "kuvaus"
#: pkg/service/response.go:43
msgid "display name"
msgstr "näyttönimi"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "vanhenemispäivä"
#: pkg/service/response.go:41
msgid "password"
msgstr "salasana"
#: pkg/service/response.go:40
msgid "permission"
msgstr "käyttöoikeus"
#: pkg/service/response.go:39
msgid "some field"
msgstr "jokin kieltä"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} ladattiin julkisen linkin {token} kautta"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} lisäsi {resource} kansioon {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} lisäsi {sharee} jäseneksi avaruuteen {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} poisti {resource} kansiosta {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} siirsi {resource} kansioon {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} poisti linkin resurssiin {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} poisti {sharee} resurssista {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} poisti {sharee} avaruudesta {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} muutti kohteen {oldResource} uudeksi nimeksi {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} jakoi {resource} linkin kautta"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} jakoi {resource} käyttäjän {sharee} kanssa"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr ""
"{user} päivitti kentän {field} linkkiin {token} resurssissa {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} päivitti kentän {field} resurssille {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} päivitti {resource} kansiossa {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# eric_G <junk.eg@free.fr>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: eric_G <junk.eg@free.fr>, 2025\n"
"Language-Team: French (https://app.transifex.com/qsfera-eu/teams/204053/fr/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "description"
#: pkg/service/response.go:43
msgid "display name"
msgstr "nom d'affichage"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "date d'expiration"
#: pkg/service/response.go:41
msgid "password"
msgstr "mot de passe"
#: pkg/service/response.go:40
msgid "permission"
msgstr "permission"
#: pkg/service/response.go:39
msgid "some field"
msgstr "un certain champ"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} a été téléchargé via le lien public {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} a ajouté {resource} à {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} a ajouté {sharee} en tant que membre de {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} a supprimé {resource} de {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} a déplacé {resource} vers {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} a supprimé le lien vers {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} a supprimé {sharee} de {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} a retiré {sharee} de {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} a renommé {oldResource} en {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} a partagé {resource} via un lien"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} a partagé {resource} avec {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} a mis à jour {field} pour un lien {token} sur {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} a mis à jour {field} pour la {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} a mis à jour {resource} dans {folder}"
@@ -0,0 +1,103 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# mitibor, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-04 07:46+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: mitibor, 2026\n"
"Language-Team: Hungarian (https://app.transifex.com/qsfera-eu/teams/204053/hu/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: hu\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "leírás"
#: pkg/service/response.go:43
msgid "display name"
msgstr "megjelenített név"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "lejárati dátum"
#: pkg/service/response.go:41
msgid "password"
msgstr "jelszó"
#: pkg/service/response.go:40
msgid "permission"
msgstr "jogosultság"
#: pkg/service/response.go:39
msgid "some field"
msgstr "néhány mező"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} letöltve a {token} nyilvános hivatkozáson keresztül"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} hozzáadta a {resource} elemet a {folder} mappához"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} hozzáadta a {sharee} felhasználót tagként a {space} tárhelyhez"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} törölte a {resource} elemet a {folder} mappából"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} átmozgatta a {resource} elemet a {folder} mappába"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} eltávolította a hivatkozást erre: {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} eltávolította a {sharee} felhasználót a {resource} elemről"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} eltávolította a {sharee} felhasználót a {space} tárhelyről"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} átnevezte a {oldResource} elemet erre: {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr ""
"{user} linken keresztül megosztotta a következő erőforrást: {resource}"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} megosztotta a {resource} elemet a {sharee} felhasználóval"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr ""
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr ""
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr ""
@@ -0,0 +1,103 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Simone Broglia, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Simone Broglia, 2025\n"
"Language-Team: Italian (https://app.transifex.com/qsfera-eu/teams/204053/it/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: it\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "descrizione"
#: pkg/service/response.go:43
msgid "display name"
msgstr "nome visualizzato"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "data di scadenza"
#: pkg/service/response.go:41
msgid "password"
msgstr "password"
#: pkg/service/response.go:40
msgid "permission"
msgstr "permessi"
#: pkg/service/response.go:39
msgid "some field"
msgstr "un campo"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} è stato scaricato tramite link pubblico {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} ha aggiunto {resource} alla cartella {folder} "
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} ha aggiunto {sharee} come membro dello spazio {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} ha eliminato {resource} dalla cartella {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} ha spostato {resource} nella cartella {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} ha rimosso il link a {resource} "
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} ha rimosso {sharee} da {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} ha rimosso {sharee} dallo spazio {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} ha rinominato {oldResource} in {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} ha condiviso {resource} tramite link"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} ha condiviso {resource} con {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr ""
"{user} ha aggiornato il campo {field} per il link {token} su {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} ha aggiornato il campo {field} per {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} ha aggiornato {resource} nella cartella {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# iikaka88, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-19 00:04+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: iikaka88, 2025\n"
"Language-Team: Japanese (https://app.transifex.com/qsfera-eu/teams/204053/ja/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ja\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "詳細"
#: pkg/service/response.go:43
msgid "display name"
msgstr "表示名"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "有効期限"
#: pkg/service/response.go:41
msgid "password"
msgstr "パスワード"
#: pkg/service/response.go:40
msgid "permission"
msgstr "権限"
#: pkg/service/response.go:39
msgid "some field"
msgstr "特定の項目"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} が公開リンク {token} 経由でダウンロードされました"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} が %{resource} を {folder} に追加しました"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} が {sharee} を {space} のメンバーに追加しました"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} が {folder} から {resource} を削除しました"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} が {resource} を {folder} に移動しました"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} が {resource} へのリンクを解除しました"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} が {resource} の共有から {sharee} を削除しました"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} が {space} の共有から {sharee} を削除しました"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} が {oldResource} の名前を {resource} に変更しました"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} がリンク経由で {resource} を共有しました"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} が {resource} を {sharee} と共有しました"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} が {resource} のリンク {token}の {field} を更新しました"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} が {resource} の {field} を更新しました"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} が {folder} 内の {resource} を更新しました"
@@ -0,0 +1,103 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# gapho shin, 2025
# Junghyuk Kwon <kwon@junghy.uk>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Junghyuk Kwon <kwon@junghy.uk>, 2025\n"
"Language-Team: Korean (https://app.transifex.com/qsfera-eu/teams/204053/ko/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ko\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "설명"
#: pkg/service/response.go:43
msgid "display name"
msgstr "표시 이름"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "만료일"
#: pkg/service/response.go:41
msgid "password"
msgstr "비밀번호"
#: pkg/service/response.go:40
msgid "permission"
msgstr "권한"
#: pkg/service/response.go:39
msgid "some field"
msgstr "일부 필드"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource}가 {token} 공개 링크를 통해 다운로드되었습니다"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user}가 {folder}에 {resource}을/를 추가했습니다"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user}가 {space}의 멤버로 {sharee}를 추가했습니다"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user}가 {folder}에서 {resource}을/를 삭제했습니다"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user}가 {resource}를 {folder}로 이동했습니다"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user}가 {resource}에 대한 링크를 제거했습니다"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user}가 {resource}에서 {sharee}를 제거했습니다"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user}가 {space}에서 {sharee}를 제거했습니다"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user}가 {oldResource}에서 {Resource}로 이름을 변경하였습니다."
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user}가 링크를 통해 {resource}를 공유했습니다"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user}가 {sharee}에서 {resource}를 공유했습니다"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{사용자}가 {resource}의 {token} 링크에 대해 {field}를 업데이트했습니다"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user}가 {resource}에 대해 {field}를 업데이트했습니다"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr " {user}가 {folder}의 {resource} 을/를 업데이트했습니다"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# BoneNI, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-30 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: BoneNI, 2026\n"
"Language-Team: Lao (https://app.transifex.com/qsfera-eu/teams/204053/lo/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: lo\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "ຄຳອະທິບາຍ"
#: pkg/service/response.go:43
msgid "display name"
msgstr "ຊື່ທີ່ສະແດງ"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "ວັນໝົດອາຍຸ"
#: pkg/service/response.go:41
msgid "password"
msgstr "ລະຫັດຜ່ານ"
#: pkg/service/response.go:40
msgid "permission"
msgstr "ສິດອະນຸຍາດ"
#: pkg/service/response.go:39
msgid "some field"
msgstr "ບາງຟີວ"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} ຖືກດາວໂຫຼດຜ່ານລິ້ງສາທາລະນະ {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} ໄດ້ເພີ່ມ {resource} ເຂົ້າໃນ {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} ໄດ້ເພີ່ມ {sharee} ເປັນສະມາຊິກຂອງ {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} ໄດ້ລຶບ {resource} ອອກຈາກ {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} ໄດ້ຍ້າຍ {resource} ໄປທີ່ {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} ໄດ້ລຶບລິ້ງໄປຫາ {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} ໄດ້ລຶບ {sharee} ອອກຈາກ {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} ໄດ້ລຶບ {sharee} ອອກຈາກ {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} ໄດ້ປ່ຽນຊື່ {oldResource} ເປັນ {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} ໄດ້ແບ່ງປັນ {resource} ຜ່ານລິ້ງ"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} ໄດ້ແບ່ງປັນ {resource} ກັບ {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} ໄດ້ອັບເດດ {field} ສຳລັບລິ້ງ {token} ໃນ {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} ໄດ້ອັບເດດ {field} ສຳລັບ {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} ໄດ້ອັບເດດ {resource} ໃນ {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Stephan Paternotte <stephan@paternottes.net>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-26 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Stephan Paternotte <stephan@paternottes.net>, 2025\n"
"Language-Team: Dutch (https://app.transifex.com/qsfera-eu/teams/204053/nl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: nl\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "beschrijving"
#: pkg/service/response.go:43
msgid "display name"
msgstr "weergavenaam"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "verloopdatum"
#: pkg/service/response.go:41
msgid "password"
msgstr "wachtwoord"
#: pkg/service/response.go:40
msgid "permission"
msgstr "machtiging"
#: pkg/service/response.go:39
msgid "some field"
msgstr "een veld"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} is gedownload via openbare link {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} heeft {resource} toegevoegd aan {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} heeft {sharee} als lid toegevoegd van {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} heeft {resource} verwijderd van {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} heeft {resource} verplaatst naar {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} heeft link naar {resource} verwijderd"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} heeft {sharee} verwijderd van {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} heeft {sharee} verwijderd van {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} heeft {oldResource} hernoemd tot {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} heeft {resource} gedeeld via link"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} heeft {resource} gedeeld met {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} heeft {field} bijgewerkt voor een link {token} op {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} heeft {field} bijgewerkt voor {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} heeft {resource} bijgewerkt in {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Mário Machado, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Mário Machado, 2025\n"
"Language-Team: Portuguese (https://app.transifex.com/qsfera-eu/teams/204053/pt/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pt\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "descrição"
#: pkg/service/response.go:43
msgid "display name"
msgstr "nome de exibição"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "data de expiração"
#: pkg/service/response.go:41
msgid "password"
msgstr "palavra-passe"
#: pkg/service/response.go:40
msgid "permission"
msgstr "permissão"
#: pkg/service/response.go:39
msgid "some field"
msgstr "algum campo"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} foi descarregado através da ligação pública {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} adicionou {resource} a {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} adicionou {sharee} como membro de {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} eliminou {resource} de {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} moveu {resource} para {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} removeu a ligação para {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} removeu {sharee} de {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} removeu {sharee} de {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} renomeou {oldResource} para {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} partilhou {resource} via ligação"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} partilhou {resource} com {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} atualizou {field} para a ligação {token} em {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} atualizou {field} para o {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} atualizou {resource} em {folder}"
@@ -0,0 +1,103 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Savely Krasovsky, 2025
# Lulufox, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-26 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Lulufox, 2025\n"
"Language-Team: Russian (https://app.transifex.com/qsfera-eu/teams/204053/ru/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ru\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "Описание"
#: pkg/service/response.go:43
msgid "display name"
msgstr "Отображаемое имя"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "Дата истечения срока"
#: pkg/service/response.go:41
msgid "password"
msgstr "Пароль"
#: pkg/service/response.go:40
msgid "permission"
msgstr "Разрешение"
#: pkg/service/response.go:39
msgid "some field"
msgstr "Какое-то поле"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} был загружен через публичную ссылку {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} добавил(-ла) {resource} в{folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} добавил(-а) {sharee} как члена {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} удалил(-а) {resource} из {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} перенес(-ла) {resource} в {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} удалил(-а) ссылку на {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} забрал у {sharee} доступ к {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} удалил {sharee} из участников {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} переименовал {oldResource} в {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} предоставил(-а) совместный доступ к {resource} по ссылке"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} предоставил(-а) совместный доступ к {resource} для {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} обновил(-а) {field} для ссылки {token} на {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} обновил(-а) {field} у {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} обновил(-а) {resource} в {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Daniel Nylander <po@danielnylander.se>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-20 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Daniel Nylander <po@danielnylander.se>, 2025\n"
"Language-Team: Swedish (https://app.transifex.com/qsfera-eu/teams/204053/sv/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: sv\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "beskrivning"
#: pkg/service/response.go:43
msgid "display name"
msgstr "visningsnamn"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "utgångsdatum"
#: pkg/service/response.go:41
msgid "password"
msgstr "lösenord"
#: pkg/service/response.go:40
msgid "permission"
msgstr "behörighet"
#: pkg/service/response.go:39
msgid "some field"
msgstr "något fält"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} hämtades via en offentlig länk {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} lade till {resource} till {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} lade till {sharee} som medlem av {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} raderade {resource} från {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} flyttade {resource} till {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} tog bort länk till {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} tog bort {sharee} från {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} tog bort {sharee} från {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} döpte om {oldResource} till {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} delade {resource} via länk"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} delade {resource} med {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} uppdaterade {field} för en länk {token} på {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} uppdaterade {field} för {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} uppdaterade {resource} i {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Quan Tran, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-19 00:04+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Quan Tran, 2025\n"
"Language-Team: Vietnamese (https://app.transifex.com/qsfera-eu/teams/204053/vi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: vi\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "mô tả"
#: pkg/service/response.go:43
msgid "display name"
msgstr "tên hiển thị"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "ngày hết hạn"
#: pkg/service/response.go:41
msgid "password"
msgstr "mật khẩu"
#: pkg/service/response.go:40
msgid "permission"
msgstr "quyền"
#: pkg/service/response.go:39
msgid "some field"
msgstr "một số trường"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} được tải xuống qua đường dẫn công khai {token}"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} thêm {resource} vào {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} thêm {sharee} vào {space}"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} xóa {resource} khỏi {folder}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} chuyển{resource} đến {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} xóa đường liên kết tới {resource}"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} xóa {sharee} khỏi {resource}"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} xóa {sharee} khỏi {space}"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} đổi tên {oldResource} thành {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} chia sẻ {resource} qua đường liên kết"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} chia sẻ {resource} với {sharee}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} cập nhật {field} của đường liên kết {token} đến {resource}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} cập nhật {field} của {resource}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} cập nhật {resource} trong {folder}"
@@ -0,0 +1,102 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# YQS Yang, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: YQS Yang, 2025\n"
"Language-Team: Chinese (https://app.transifex.com/qsfera-eu/teams/204053/zh/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#: pkg/service/response.go:44
msgid "description"
msgstr "描述"
#: pkg/service/response.go:43
msgid "display name"
msgstr "显示名称"
#: pkg/service/response.go:42
msgid "expiration date"
msgstr "过期日期"
#: pkg/service/response.go:41
msgid "password"
msgstr "密码"
#: pkg/service/response.go:40
msgid "permission"
msgstr "权限"
#: pkg/service/response.go:39
msgid "some field"
msgstr "特定字段"
#: pkg/service/response.go:26
msgid "{resource} was downloaded via public link {token}"
msgstr "{resource} 已通过公开链接 {token} 下载"
#: pkg/service/response.go:24
msgid "{user} added {resource} to {folder}"
msgstr "{user} 将 {resource} 添加到 {folder}"
#: pkg/service/response.go:36
msgid "{user} added {sharee} as member of {space}"
msgstr "{user} 添加 {sharee} 成为 {space} 的成员"
#: pkg/service/response.go:27
msgid "{user} deleted {resource} from {folder}"
msgstr "{user} 从 {folder} 中删除 {resource}"
#: pkg/service/response.go:28
msgid "{user} moved {resource} to {folder}"
msgstr "{user} 将 {resource} 移动到 {folder}"
#: pkg/service/response.go:35
msgid "{user} removed link to {resource}"
msgstr "{user} 移除了 {resource} 的链接"
#: pkg/service/response.go:32
msgid "{user} removed {sharee} from {resource}"
msgstr "{user} 将 {sharee} 从 {resource} 移除"
#: pkg/service/response.go:37
msgid "{user} removed {sharee} from {space}"
msgstr "{user} 将 {sharee} 从 {space} 移除"
#: pkg/service/response.go:29
msgid "{user} renamed {oldResource} to {resource}"
msgstr "{user} 将 {oldResource} 重命名为 {resource}"
#: pkg/service/response.go:33
msgid "{user} shared {resource} via link"
msgstr "{user} 通过链接分享了 {resource}"
#: pkg/service/response.go:30
msgid "{user} shared {resource} with {sharee}"
msgstr "{user} 与 {sharee} 分享了 {resource}"
#: pkg/service/response.go:34
msgid "{user} updated {field} for a link {token} on {resource}"
msgstr "{user} 更新了 {resource} 上链接 {token} 的 {field}"
#: pkg/service/response.go:31
msgid "{user} updated {field} for the {resource}"
msgstr "{user} 更新了 {resource} 的 {field}"
#: pkg/service/response.go:25
msgid "{user} updated {resource} in {folder}"
msgstr "{user} 在 {folder} 中更新了 {resource}"
@@ -0,0 +1,129 @@
package service
import (
"context"
"encoding/json"
"fmt"
"log"
"github.com/nats-io/nats.go"
"github.com/vmihailenco/msgpack/v5"
)
const activitylogVersionKey = "activitylog.version"
const currentMigrationVersion = "1"
// RunMigrations checks the activitylog data version and runs migrations if necessary.
// It should be called during service startup, after the NATS KeyValue store is initialized.
func (a *ActivitylogService) runMigrations(ctx context.Context, kv nats.KeyValue) error {
entry, err := kv.Get(activitylogVersionKey)
if err == nats.ErrKeyNotFound {
a.log.Info().Msg("activitylog version key not found. Running migration to V1...")
return a.migrateToV1(ctx, kv)
} else if err != nil {
return fmt.Errorf("failed to get activitylog version from NATS KV store: %w", err)
}
version := string(entry.Value())
if version == currentMigrationVersion {
a.log.Debug().Str("currentVersion", version).Msg("No migration needed")
return nil
}
// If version is something else, it might indicate a future version or an unexpected state.
// Add logic here if more complex version handling is needed.
return fmt.Errorf("unexpected activitylog version: %s, expected %s or older", version, currentMigrationVersion)
}
// migrateToV1 performs the data migration to version 1.
// It iterates over all keys, expecting their values to be JSON arrays of strings.
// For each such key, it creates a new key in the format "originalKey.count.timestamp"
// and stores the original list of strings (re-marshalled to messagepack) as its value.
// Finally, it sets the activitylog.version key to "1".
func (a *ActivitylogService) migrateToV1(_ context.Context, kv nats.KeyValue) error {
lister, err := kv.ListKeys()
if err != nil {
return fmt.Errorf("migrateToV1: failed to list keys from NATS KV store: %w", err)
}
migratedCount := 0
skippedCount := 0
keyChan := lister.Keys()
defer lister.Stop()
// keyValueEnvelope is the data structure used by the go micro plugin which was used previously.
type keyValueEnvelope struct {
Key string `json:"key"`
Data []byte `json:"data"`
Metadata map[string]any `json:"metadata"`
}
for key := range keyChan {
if key == activitylogVersionKey {
skippedCount++
continue // Skip the version key itself
}
// Get the original value
entry, err := kv.Get(key)
if err != nil {
a.log.Error().Err(err).Str("key", key).Msg("migrateToV1: Failed to get value for key. Skipping.")
skippedCount++
continue
}
valBytes := entry.Value()
val := keyValueEnvelope{}
// Unmarshal the value into the keyValueEnvelope structure
if err := json.Unmarshal(valBytes, &val); err != nil {
a.log.Error().Err(err).Str("key", key).Msg("migrateToV1: Value for key ss not a keyValueEnvelope. Skipping.")
skippedCount++
continue
}
// Unmarshal value into a list of strings
var activities []RawActivity
if err := msgpack.Unmarshal(val.Data, &activities); err != nil {
if err := json.Unmarshal(val.Data, &activities); err != nil {
// This key's value is not a JSON array of strings. Skip it.
a.log.Error().Err(err).Str("key", key).Msg("migrateToV1: Value for key is not a msgback or JSON array of strings. Skipping.")
skippedCount++
continue
}
}
// Construct the new key
newKey := natsKey(val.Key, len(activities))
newValue, err := msgpack.Marshal(activities)
if err != nil {
a.log.Error().Err(err).Str("key", key).Msg("migrateToV1: Failed to marshal activities. Skipping.")
skippedCount++
continue
}
// Write the value (the list of strings, marshalled as messagepack) under the new key
if _, err := kv.Put(newKey, newValue); err != nil {
a.log.Error().Err(err).Str("newKey", newKey).Str("key", key).Msg("migrateToV1: Failed to put new key. Skipping.")
skippedCount++
continue
}
// delete old key, it's no longer needed
if err := kv.Delete(key); err != nil {
log.Printf("migrateToV1: Failed to delete old key '%s' after migration: %v. Skipping deletion.", key, err)
skippedCount++
continue
}
migratedCount++
}
// Set the activitylog version to "1" after migration
if _, err := kv.PutString(activitylogVersionKey, currentMigrationVersion); err != nil {
return fmt.Errorf("migrateToV1: failed to set activitylog version key to '%s' in NATS KV store: %w", currentMigrationVersion, err)
}
a.log.Info().Int("migrated", migratedCount).Int("skipped", skippedCount).Msg("Migration to V1 complete")
return nil
}
@@ -0,0 +1,105 @@
package service
import (
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/qsfera/server/pkg/log"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/activitylog/pkg/config"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
microstore "go-micro.dev/v4/store"
"go.opentelemetry.io/otel/trace"
)
// Option for the activitylog service
type Option func(*Options)
// Options for the activitylog service
type Options struct {
Logger log.Logger
Config *config.Config
TraceProvider trace.TracerProvider
Stream events.Stream
RegisteredEvents []events.Unmarshaller
Store microstore.Store
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
Mux *chi.Mux
HistoryClient ehsvc.EventHistoryService
ValueClient settingssvc.ValueService
WriteBufferDuration time.Duration
MaxActivities int
}
// Logger configures a logger for the activitylog service
func Logger(log log.Logger) Option {
return func(o *Options) {
o.Logger = log
}
}
// Config adds the config for the activitylog service
func Config(c *config.Config) Option {
return func(o *Options) {
o.Config = c
}
}
// TraceProvider adds a tracer provider for the activitylog service
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = tp
}
}
// Stream configures an event stream for the clientlog service
func Stream(s events.Stream) Option {
return func(o *Options) {
o.Stream = s
}
}
// RegisteredEvents registers the events the service should listen to
func RegisteredEvents(e []events.Unmarshaller) Option {
return func(o *Options) {
o.RegisteredEvents = e
}
}
// Store configures the store to use
func Store(store microstore.Store) Option {
return func(o *Options) {
o.Store = store
}
}
// GatewaySelector adds a grpc client selector for the gateway service
func GatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) Option {
return func(o *Options) {
o.GatewaySelector = gatewaySelector
}
}
// Mux defines the muxer for the service
func Mux(m *chi.Mux) Option {
return func(o *Options) {
o.Mux = m
}
}
// HistoryClient adds a grpc client for the eventhistory service
func HistoryClient(hc ehsvc.EventHistoryService) Option {
return func(o *Options) {
o.HistoryClient = hc
}
}
// ValueClient adds a grpc client for the value service
func ValueClient(vs settingssvc.ValueService) Option {
return func(o *Options) {
o.ValueClient = vs
}
}
@@ -0,0 +1,357 @@
package service
import (
"context"
"fmt"
"path/filepath"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/l10n"
)
// Translations
var (
MessageResourceCreated = l10n.Template("{user} added {resource} to {folder}")
MessageResourceUpdated = l10n.Template("{user} updated {resource} in {folder}")
MessageResourceDownloaded = l10n.Template("{resource} was downloaded via public link {token}")
MessageResourceTrashed = l10n.Template("{user} deleted {resource} from {folder}")
MessageResourceMoved = l10n.Template("{user} moved {resource} to {folder}")
MessageResourceRenamed = l10n.Template("{user} renamed {oldResource} to {resource}")
MessageShareCreated = l10n.Template("{user} shared {resource} with {sharee}")
MessageShareUpdated = l10n.Template("{user} updated {field} for the {resource}")
MessageShareDeleted = l10n.Template("{user} removed {sharee} from {resource}")
MessageLinkCreated = l10n.Template("{user} shared {resource} via link")
MessageLinkUpdated = l10n.Template("{user} updated {field} for a link {token} on {resource}")
MessageLinkDeleted = l10n.Template("{user} removed link to {resource}")
MessageSpaceShared = l10n.Template("{user} added {sharee} as member of {space}")
MessageSpaceUnshared = l10n.Template("{user} removed {sharee} from {space}")
StrSomeField = l10n.Template("some field")
StrPermission = l10n.Template("permission")
StrPassword = l10n.Template("password")
StrExpirationDate = l10n.Template("expiration date")
StrDisplayName = l10n.Template("display name")
StrDescription = l10n.Template("description")
)
// GetActivitiesResponse is the response on GET activities requests
type GetActivitiesResponse struct {
Activities []libregraph.Activity `json:"value"`
}
// Resource represents an item such as a file or folder
type Resource struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Actor represents a user
type Actor struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
}
// Sharee represents a share reciever (group or user)
type Sharee struct {
ID string `json:"id"`
DisplayName string `json:"displayName"`
ShareType string `json:"shareType"`
}
// ActivityOption allows setting variables for an activity
type ActivityOption func(context.Context, gateway.GatewayAPIClient, map[string]any) error
// WithResource sets the resource variable for an activity
func WithResource(ref *provider.Reference, addSpace bool, explicitResourceName string) ActivityOption {
return func(ctx context.Context, gwc gateway.GatewayAPIClient, vars map[string]any) error {
info, err := utils.GetResource(ctx, ref, gwc)
if err != nil {
if explicitResourceName == "" {
explicitResourceName = filepath.Base(ref.GetPath())
}
vars["resource"] = Resource{
Name: explicitResourceName,
}
n := getFolderName(ctx, gwc, ref)
vars["folder"] = Resource{
Name: n,
}
return err
}
if explicitResourceName == "" {
explicitResourceName = info.GetName()
}
vars["resource"] = Resource{
ID: storagespace.FormatResourceID(info.GetId()),
Name: explicitResourceName,
}
if addSpace {
vars["space"] = Resource{
ID: info.GetSpace().GetId().GetOpaqueId(),
Name: info.GetSpace().GetName(),
}
}
parent, err := utils.GetResourceByID(ctx, info.GetParentId(), gwc)
if err != nil {
return err
}
vars["folder"] = Resource{
ID: info.GetParentId().GetOpaqueId(),
Name: parent.GetName(),
}
return nil
}
}
// WithOldResource sets the oldResource variable for an activity
func WithOldResource(ref *provider.Reference) ActivityOption {
return func(_ context.Context, _ gateway.GatewayAPIClient, vars map[string]any) error {
name := filepath.Base(ref.GetPath())
vars["oldResource"] = Resource{
Name: name,
}
return nil
}
}
// WithTrashedResource sets the resource variable if the resource is trashed
func WithTrashedResource(ref *provider.Reference, rid *provider.ResourceId) ActivityOption {
return func(ctx context.Context, gwc gateway.GatewayAPIClient, vars map[string]any) error {
vars["resource"] = Resource{
Name: filepath.Base(ref.GetPath()),
}
n := getFolderName(ctx, gwc, ref)
vars["folder"] = Resource{
Name: n,
}
resp, err := gwc.ListRecycle(ctx, &provider.ListRecycleRequest{
Ref: ref,
Key: rid.GetOpaqueId(),
})
if err != nil {
return err
}
if resp.GetStatus().GetCode() != rpc.Code_CODE_OK {
return fmt.Errorf("error listing recycle: %s", resp.GetStatus().GetMessage())
}
for _, item := range resp.GetRecycleItems() {
if item.GetKey() == rid.GetOpaqueId() {
vars["resource"] = Resource{
ID: storagespace.FormatResourceID(rid),
Name: filepath.Base(item.GetRef().GetPath()),
}
in := filepath.Base(filepath.Dir(item.GetRef().GetPath()))
if in != "." && in != "/" {
vars["folder"] = Resource{
Name: in,
}
}
return nil
}
}
return nil
}
}
// WithUser sets the user variable for an Activity
func WithUser(uid *user.UserId, u *user.User, impersonator *user.User) ActivityOption {
return func(ctx context.Context, gwc gateway.GatewayAPIClient, vars map[string]any) error {
var target *user.User
switch {
case impersonator != nil:
target = impersonator
case u != nil:
target = u
case uid != nil:
us, err := utils.GetUserNoGroups(ctx, uid, gwc)
target = us
if err != nil {
target = &user.User{
Id: uid,
DisplayName: "DeletedUser",
}
}
default:
return fmt.Errorf("no user provided")
}
vars["user"] = Actor{
ID: target.GetId().GetOpaqueId(),
DisplayName: target.GetDisplayName(),
}
return nil
}
}
// WithSharee sets the sharee variable for an activity
func WithSharee(uid *user.UserId, gid *group.GroupId) ActivityOption {
return func(ctx context.Context, gwc gateway.GatewayAPIClient, vars map[string]any) error {
switch {
case uid != nil:
u, err := utils.GetUserNoGroups(ctx, uid, gwc)
if err != nil {
vars["sharee"] = Sharee{
DisplayName: "DeletedUser",
ShareType: "user",
}
return err
}
vars["sharee"] = Sharee{
ID: uid.GetOpaqueId(),
DisplayName: u.GetUsername(),
ShareType: "user",
}
case gid != nil:
vars["sharee"] = Sharee{
ID: gid.GetOpaqueId(),
DisplayName: "DeletedGroup",
ShareType: "group",
}
r, err := gwc.GetGroup(ctx, &group.GetGroupRequest{GroupId: gid})
if err != nil {
return fmt.Errorf("error getting group: %w", err)
}
if r.GetStatus().GetCode() != rpc.Code_CODE_OK {
return fmt.Errorf("error getting group: %s", r.GetStatus().GetMessage())
}
vars["sharee"] = Sharee{
ID: gid.GetOpaqueId(),
DisplayName: r.GetGroup().GetDisplayName(),
ShareType: "group",
}
}
return nil
}
}
// WithSpace sets the space variable for an activity
func WithSpace(spaceid *provider.StorageSpaceId) ActivityOption {
return func(ctx context.Context, gwc gateway.GatewayAPIClient, vars map[string]any) error {
s, err := utils.GetSpace(ctx, spaceid.GetOpaqueId(), gwc)
if err != nil {
vars["space"] = Resource{
ID: spaceid.GetOpaqueId(),
Name: "DeletedSpace",
}
return err
}
vars["space"] = Resource{
ID: s.GetId().GetOpaqueId(),
Name: s.GetName(),
}
return nil
}
}
// WithTranslation sets a variable that translation is needed for
func WithTranslation(t *l10n.Translator, locale string, key string, values []string) ActivityOption {
return func(_ context.Context, _ gateway.GatewayAPIClient, vars map[string]any) error {
f := t.Translate(StrSomeField, locale)
if len(values) > 0 {
for i := range values {
values[i] = t.Translate(mapField(values[i]), locale)
}
f = strings.Join(values, ", ")
}
vars[key] = Resource{
Name: f,
}
return nil
}
}
// WithVar sets a variable for an activity
func WithVar(key, id, name string) ActivityOption {
return func(_ context.Context, _ gateway.GatewayAPIClient, vars map[string]any) error {
vars[key] = Resource{
ID: id,
Name: name,
}
return nil
}
}
// NewActivity creates a new activity
func NewActivity(message string, ts time.Time, eventID string, vars map[string]any) libregraph.Activity {
return libregraph.Activity{
Id: eventID,
Times: libregraph.ActivityTimes{RecordedTime: ts},
Template: libregraph.ActivityTemplate{
Message: message,
Variables: vars,
},
}
}
// GetVars calls other service to gather the required data for the activity variables
func (s *ActivitylogService) GetVars(ctx context.Context, opts ...ActivityOption) (map[string]any, error) {
gwc, err := s.gws.Next()
if err != nil {
return nil, err
}
vars := make(map[string]any)
for _, opt := range opts {
if err := opt(ctx, gwc, vars); err != nil {
s.log.Info().Err(err).Msg("error getting activity vars")
}
}
return vars, nil
}
func getFolderName(ctx context.Context, gwc gateway.GatewayAPIClient, ref *provider.Reference) string {
n := filepath.Base(filepath.Dir(ref.GetPath()))
if n == "." || n == "/" {
s, err := utils.GetSpace(ctx, toSpace(ref).GetOpaqueId(), gwc)
if err == nil {
n = s.GetName()
} else {
n = "root"
}
}
return n
}
func mapField(val string) string {
switch val {
case "TYPE_PERMISSIONS", "permission", "permissions":
return StrPermission
case "TYPE_PASSWORD", "password":
return StrPassword
case "TYPE_EXPIRATION", "expiration":
return StrExpirationDate
case "TYPE_DISPLAYNAME":
return StrDisplayName
case "TYPE_DESCRIPTION":
return StrDescription
}
return StrSomeField
}
@@ -0,0 +1,661 @@
package service
import (
"context"
"encoding/base32"
"encoding/json"
"fmt"
"path/filepath"
"reflect"
"sort"
"strconv"
"strings"
"sync"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/jellydator/ttlcache/v2"
"github.com/nats-io/nats.go"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/pkg/errors"
"github.com/vmihailenco/msgpack/v5"
"go.opentelemetry.io/otel/trace"
"github.com/qsfera/server/pkg/log"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/activitylog/pkg/config"
)
// Nats runs into max payload exceeded errors at around 7k activities. Let's keep a buffer.
var _maxActivitiesDefault = 6000
// RawActivity represents an activity as it is stored in the activitylog store
type RawActivity struct {
EventID string `json:"event_id"`
Depth int `json:"depth"`
Timestamp time.Time `json:"timestamp"`
}
// ActivitylogService logs events per resource
type ActivitylogService struct {
cfg *config.Config
log log.Logger
events <-chan events.Event
gws pool.Selectable[gateway.GatewayAPIClient]
mux *chi.Mux
evHistory ehsvc.EventHistoryService
valService settingssvc.ValueService
lock sync.RWMutex
tp trace.TracerProvider
tracer trace.Tracer
debouncer *Debouncer
parentIdCache *ttlcache.Cache
natskv nats.KeyValue
maxActivities int
registeredEvents map[string]events.Unmarshaller
}
type Debouncer struct {
after time.Duration
f func(id string, ra []RawActivity) error
pending sync.Map
inProgress sync.Map
mutex sync.Mutex
}
type queueItem struct {
activities []RawActivity
timer *time.Timer
}
type batchInfo struct {
key string
count int
timestamp time.Time
}
// NewDebouncer returns a new Debouncer instance
func NewDebouncer(d time.Duration, f func(id string, ra []RawActivity) error) *Debouncer {
return &Debouncer{
after: d,
f: f,
pending: sync.Map{},
inProgress: sync.Map{},
}
}
// Debounce restarts the debounce timer for the given space
func (d *Debouncer) Debounce(id string, ra RawActivity) {
if d.after == 0 {
d.f(id, []RawActivity{ra})
return
}
d.mutex.Lock()
defer d.mutex.Unlock()
activities := []RawActivity{ra}
item := &queueItem{
activities: activities,
}
if i, ok := d.pending.Load(id); ok {
// if the item is already in the queue, append the new activities
item, ok = i.(*queueItem)
if ok {
item.activities = append(item.activities, ra)
}
}
if item.timer == nil {
item.timer = time.AfterFunc(d.after, func() {
if _, ok := d.inProgress.Load(id); ok {
// Reschedule this run for when the previous run has finished
d.mutex.Lock()
if i, ok := d.pending.Load(id); ok {
i.(*queueItem).timer.Reset(d.after)
}
d.mutex.Unlock()
return
}
d.pending.Delete(id)
d.inProgress.Store(id, true)
defer d.inProgress.Delete(id)
d.f(id, item.activities)
})
}
d.pending.Store(id, item)
}
// New creates a new ActivitylogService
func New(opts ...Option) (*ActivitylogService, error) {
o := &Options{
MaxActivities: _maxActivitiesDefault,
}
for _, opt := range opts {
opt(o)
}
if o.Stream == nil {
return nil, errors.New("stream is required")
}
ch, err := events.Consume(o.Stream, o.Config.Service.Name, o.RegisteredEvents...)
if err != nil {
return nil, err
}
cache := ttlcache.NewCache()
err = cache.SetTTL(30 * time.Second)
if err != nil {
return nil, err
}
// Connect to NATS servers
natsOptions := nats.Options{
Servers: o.Config.Store.Nodes,
}
conn, err := natsOptions.Connect()
if err != nil {
return nil, err
}
js, err := conn.JetStream()
if err != nil {
return nil, err
}
kv, err := js.KeyValue(o.Config.Store.Database)
if err != nil {
if !errors.Is(err, nats.ErrBucketNotFound) {
return nil, errors.Wrapf(err, "Failed to get bucket (%s)", o.Config.Store.Database)
}
kv, err = js.CreateKeyValue(&nats.KeyValueConfig{
Bucket: o.Config.Store.Database,
})
if err != nil {
return nil, errors.Wrapf(err, "Failed to create bucket (%s)", o.Config.Store.Database)
}
}
if err != nil {
return nil, err
}
s := &ActivitylogService{
log: o.Logger,
cfg: o.Config,
events: ch,
gws: o.GatewaySelector,
mux: o.Mux,
evHistory: o.HistoryClient,
valService: o.ValueClient,
lock: sync.RWMutex{},
registeredEvents: make(map[string]events.Unmarshaller),
tp: o.TraceProvider,
tracer: o.TraceProvider.Tracer("github.com/qsfera/server/services/activitylog/pkg/service"),
parentIdCache: cache,
maxActivities: o.Config.MaxActivities,
natskv: kv,
}
s.debouncer = NewDebouncer(o.Config.WriteBufferDuration, s.storeActivity)
// run migrations
err = s.runMigrations(context.Background(), kv)
if err != nil {
return nil, err
}
s.mux.Get("/graph/v1beta1/extensions/org.libregraph/activities", s.HandleGetItemActivities)
for _, e := range o.RegisteredEvents {
typ := reflect.TypeOf(e)
s.registeredEvents[typ.String()] = e
}
go s.Run()
return s, nil
}
// Run runs the service
func (a *ActivitylogService) Run() {
for e := range a.events {
var err error
switch ev := e.Event.(type) {
case events.UploadReady:
err = a.AddActivity(ev.FileRef, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
case events.FileTouched:
err = a.AddActivity(ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
// Disabled https://github.com/owncloud/ocis/issues/10293
//case events.FileDownloaded:
// we are only interested in public link downloads - so no need to store others.
//if ev.ImpersonatingUser.GetDisplayName() == "Public" {
// err = a.AddActivity(ev.Ref, e.ID, utils.TSToTime(ev.Timestamp))
//}
case events.ContainerCreated:
err = a.AddActivity(ev.Ref, ev.ParentID, e.ID, utils.TSToTime(ev.Timestamp))
case events.ItemTrashed:
err = a.AddActivityTrashed(ev.ID, ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
case events.ItemPurged:
err = a.RemoveResource(ev.ID)
case events.ItemMoved:
// remove the cached parent id for this resource
a.removeCachedParentID(ev.Ref)
err = a.AddActivity(ev.Ref, nil, e.ID, utils.TSToTime(ev.Timestamp))
case events.ShareCreated:
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
case events.ShareUpdated:
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
}
case events.ShareRemoved:
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, ev.Timestamp)
case events.LinkCreated:
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.CTime))
case events.LinkUpdated:
if ev.Sharer != nil && ev.ItemID != nil && ev.Sharer.GetOpaqueId() != ev.ItemID.GetSpaceId() {
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.MTime))
}
case events.LinkRemoved:
err = a.AddActivity(toRef(ev.ItemID), nil, e.ID, utils.TSToTime(ev.Timestamp))
case events.SpaceShared:
err = a.AddSpaceActivity(ev.ID, e.ID, ev.Timestamp)
case events.SpaceUnshared:
err = a.AddSpaceActivity(ev.ID, e.ID, ev.Timestamp)
}
if err != nil {
a.log.Error().Err(err).Interface("event", e).Msg("could not process event")
}
}
}
// AddActivity adds the activity to the given resource and all its parents
func (a *ActivitylogService) AddActivity(initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
gwc, err := a.gws.Next()
if err != nil {
return fmt.Errorf("cant get gateway client: %w", err)
}
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
return fmt.Errorf("cant get service user context: %w", err)
}
var span trace.Span
ctx, span = a.tracer.Start(ctx, "AddActivity")
defer span.End()
return a.addActivity(ctx, initRef, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return utils.GetResource(ctx, ref, gwc)
})
}
// AddActivityTrashed adds the activity to given trashed resource and all its former parents
func (a *ActivitylogService) AddActivityTrashed(resourceID *provider.ResourceId, reference *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time) error {
gwc, err := a.gws.Next()
if err != nil {
return fmt.Errorf("cant get gateway client: %w", err)
}
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
return fmt.Errorf("cant get service user context: %w", err)
}
// store activity on trashed item
if err := a.storeActivity(storagespace.FormatResourceID(resourceID), []RawActivity{
{
EventID: eventID,
Depth: 0,
Timestamp: timestamp,
},
}); err != nil {
return fmt.Errorf("could not store activity: %w", err)
}
// get previous parent
ref := &provider.Reference{
ResourceId: reference.GetResourceId(),
Path: filepath.Dir(reference.GetPath()),
}
var span trace.Span
ctx, span = a.tracer.Start(ctx, "AddActivityTrashed")
defer span.End()
return a.addActivity(ctx, ref, parentId, eventID, timestamp, func(ctx context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return utils.GetResource(ctx, ref, gwc)
})
}
// AddSpaceActivity adds the activity to the given spaceroot
func (a *ActivitylogService) AddSpaceActivity(spaceID *provider.StorageSpaceId, eventID string, timestamp time.Time) error {
// spaceID is in format <providerid>$<spaceid>
// activitylog service uses format <providerid>$<spaceid>!<resourceid>
// lets do some converting, shall we?
rid, err := storagespace.ParseID(spaceID.GetOpaqueId())
if err != nil {
return fmt.Errorf("could not parse space id: %w", err)
}
rid.OpaqueId = rid.GetSpaceId()
return a.storeActivity(storagespace.FormatResourceID(&rid), []RawActivity{
{
EventID: eventID,
Depth: 0,
Timestamp: timestamp,
},
})
}
// Activities returns the activities for the given resource
func (a *ActivitylogService) Activities(rid *provider.ResourceId) ([]RawActivity, error) {
a.lock.RLock()
defer a.lock.RUnlock()
return a.activities(rid)
}
// RemoveActivities removes the activities from the given resource
func (a *ActivitylogService) RemoveActivities(rid *provider.ResourceId, toDelete map[string]struct{}) error {
a.lock.Lock()
defer a.lock.Unlock()
curActivities, err := a.activities(rid)
if err != nil {
return err
}
var acts []RawActivity
for _, a := range curActivities {
if _, ok := toDelete[a.EventID]; !ok {
acts = append(acts, a)
}
}
b, err := json.Marshal(acts)
if err != nil {
return err
}
_, err = a.natskv.Put(storagespace.FormatResourceID(rid), b)
return err
}
// RemoveResource removes the resource from the store
func (a *ActivitylogService) RemoveResource(rid *provider.ResourceId) error {
if rid == nil {
return fmt.Errorf("resource id is required")
}
a.lock.Lock()
defer a.lock.Unlock()
return a.natskv.Delete(storagespace.FormatResourceID(rid))
}
func (a *ActivitylogService) activities(rid *provider.ResourceId) ([]RawActivity, error) {
resourceID := storagespace.FormatResourceID(rid)
glob := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
watcher, err := a.natskv.Watch(glob, nats.IgnoreDeletes())
if err != nil {
return nil, err
}
defer watcher.Stop()
var activities []RawActivity
for update := range watcher.Updates() {
if update == nil {
break
}
var batchActivities []RawActivity
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
}
activities = append(activities, batchActivities...)
}
return activities, nil
}
// note: getResource is abstracted to allow unit testing, in general this will just be utils.GetResource
func (a *ActivitylogService) addActivity(ctx context.Context, initRef *provider.Reference, parentId *provider.ResourceId, eventID string, timestamp time.Time, getResource func(context.Context, *provider.Reference) (*provider.ResourceInfo, error)) error {
var (
err error
depth int
ref = initRef
)
ctx, span := a.tracer.Start(ctx, "addActivity")
defer span.End()
for {
var info *provider.ResourceInfo
id := ref.GetResourceId()
if ref.Path != "" {
// Path based reference, we need to resolve the resource id
ctx, span = a.tracer.Start(ctx, "addActivity.getResource")
info, err = getResource(ctx, ref)
span.End()
if err != nil {
return fmt.Errorf("could not get resource info: %w", err)
}
id = info.GetId()
}
if id == nil {
return fmt.Errorf("resource id is required")
}
key := storagespace.FormatResourceID(id)
a.debouncer.Debounce(key, RawActivity{
EventID: eventID,
Depth: depth,
Timestamp: timestamp,
})
if id.OpaqueId == id.SpaceId {
// we are at the root of the space, no need to go further
break
}
// check if parent id is cached
// parent id is cached in the format <storageid>$<spaceid>!<resourceid>
// if it is not cached, get the resource info and cache it
if parentId == nil {
if v, err := a.parentIdCache.Get(key); err != nil {
if info == nil {
ctx, span := a.tracer.Start(ctx, "addActivity.getResource parent")
info, err = getResource(ctx, ref)
span.End()
if err != nil || info.GetParentId() == nil || info.GetParentId().GetOpaqueId() == "" {
return fmt.Errorf("could not get parent id: %w", err)
}
}
parentId = info.GetParentId()
a.parentIdCache.Set(key, parentId)
} else {
parentId = v.(*provider.ResourceId)
}
} else {
a.log.Debug().Msg("parent id is cached")
}
depth++
ref = &provider.Reference{ResourceId: parentId}
parentId = nil // reset parent id so it's not reused in the next iteration
}
return nil
}
func (a *ActivitylogService) storeActivity(resourceID string, activities []RawActivity) error {
a.lock.Lock()
defer a.lock.Unlock()
ctx, span := a.tracer.Start(context.Background(), "storeActivity")
defer span.End()
_, subspan := a.tracer.Start(ctx, "storeActivity.Marshal")
b, err := msgpack.Marshal(activities)
if err != nil {
return err
}
subspan.End()
_, subspan = a.tracer.Start(ctx, "storeActivity.natskv.Put")
key := natsKey(resourceID, len(activities))
_, err = a.natskv.Put(key, b)
if err != nil {
return err
}
subspan.End()
ctx, subspan = a.tracer.Start(ctx, "storeActivity.enforceMaxActivities")
a.enforceMaxActivities(ctx, resourceID)
subspan.End()
return nil
}
func (a *ActivitylogService) enforceMaxActivities(ctx context.Context, resourceID string) {
if a.maxActivities <= 0 {
return
}
key := fmt.Sprintf("%s.>", base32.StdEncoding.EncodeToString([]byte(resourceID)))
_, subspan := a.tracer.Start(ctx, "enforceMaxActivities.watch")
watcher, err := a.natskv.Watch(key, nats.IgnoreDeletes())
if err != nil {
a.log.Error().Err(err).Str("resourceID", resourceID).Msg("could not watch")
return
}
defer watcher.Stop()
var keys []string
for update := range watcher.Updates() {
if update == nil {
break
}
var batchActivities []RawActivity
if err := msgpack.Unmarshal(update.Value(), &batchActivities); err != nil {
a.log.Debug().Err(err).Str("resourceID", resourceID).Msg("could not unmarshal messagepack, trying json")
}
keys = append(keys, update.Key())
}
subspan.End()
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.compile")
// Parse keys into batches
batches := make([]batchInfo, 0)
var activitiesCount int
for _, k := range keys {
parts := strings.SplitN(k, ".", 3)
if len(parts) < 3 {
a.log.Warn().Str("key", k).Msg("skipping key, not enough parts")
continue
}
c, err := strconv.Atoi(parts[1])
if err != nil {
a.log.Warn().Str("key", k).Msg("skipping key, can not parse count")
continue
}
// parse timestamp
nano, err := strconv.ParseInt(parts[2], 10, 64)
if err != nil {
a.log.Warn().Str("key", k).Msg("skipping key, can not parse timestamp")
continue
}
batches = append(batches, batchInfo{
key: k,
count: c,
timestamp: time.Unix(0, nano),
})
activitiesCount += c
}
// sort batches by timestamp
sort.Slice(batches, func(i, j int) bool {
return batches[i].timestamp.Before(batches[j].timestamp)
})
subspan.End()
_, subspan = a.tracer.Start(ctx, "enforceMaxActivities.delete")
// remove oldest keys until we are at max activities
for _, b := range batches {
if activitiesCount-b.count < a.maxActivities {
break
}
activitiesCount -= b.count
err = a.natskv.Delete(b.key)
if err != nil {
a.log.Error().Err(err).Str("key", b.key).Msg("could not delete key")
break
}
}
subspan.End()
}
func toRef(r *provider.ResourceId) *provider.Reference {
return &provider.Reference{
ResourceId: r,
}
}
func toSpace(r *provider.Reference) *provider.StorageSpaceId {
return &provider.StorageSpaceId{
OpaqueId: storagespace.FormatStorageID(r.GetResourceId().GetStorageId(), r.GetResourceId().GetSpaceId()),
}
}
func (a *ActivitylogService) removeCachedParentID(ref *provider.Reference) {
purgeId := ref.GetResourceId()
if ref.GetPath() != "" {
gwc, err := a.gws.Next()
if err != nil {
a.log.Error().Err(err).Msg("could not get gateway client")
return
}
ctx, err := utils.GetServiceUserContext(a.cfg.ServiceAccount.ServiceAccountID, gwc, a.cfg.ServiceAccount.ServiceAccountSecret)
if err != nil {
a.log.Error().Err(err).Msg("could not get service user context")
return
}
info, err := utils.GetResource(ctx, ref, gwc)
if err != nil {
a.log.Error().Err(err).Msg("could not get resource info")
return
}
purgeId = info.GetId()
}
if err := a.parentIdCache.Remove(storagespace.FormatResourceID(purgeId)); err != nil {
a.log.Error().Interface("event", ref).Err(err).Msg("could not delete parent id cache")
}
}
func natsKey(resourceID string, activitiesCount int) string {
return fmt.Sprintf("%s.%d.%d",
base32.StdEncoding.EncodeToString([]byte(resourceID)),
activitiesCount,
time.Now().UnixNano())
}
@@ -0,0 +1,13 @@
package service_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestService(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Service Suite")
}
@@ -0,0 +1,305 @@
package service
import (
"context"
"net"
"os"
"path/filepath"
"time"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
nserver "github.com/nats-io/nats-server/v2/server"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/services/activitylog/pkg/config"
eventsmocks "github.com/opencloud-eu/reva/v2/pkg/events/mocks"
"github.com/test-go/testify/mock"
"go.opentelemetry.io/otel/trace/noop"
)
var (
server *nserver.Server
tmpdir string
)
func getFreeLocalhostPort() (int, error) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return -1, err
}
port := l.Addr().(*net.TCPAddr).Port
_ = l.Close() // Close the listener immediately to free the port
return port, nil
}
// Spawn a nats server and a JetStream instance for the duration of the test suite.
// The different tests need to make sure to use different databases to avoid conflicts.
var _ = SynchronizedBeforeSuite(func() {
port, err := getFreeLocalhostPort()
server, err = nserver.NewServer(&nserver.Options{
Port: port,
})
Expect(err).ToNot(HaveOccurred())
tmpdir, err = os.MkdirTemp("", "activitylog-test")
natsdir := filepath.Join(tmpdir, "nats-js")
jsConf := &nserver.JetStreamConfig{
StoreDir: natsdir,
}
// first start NATS
go server.Start()
time.Sleep(time.Second)
// second start JetStream
err = server.EnableJetStream(jsConf)
Expect(err).ToNot(HaveOccurred())
}, func() {})
var _ = SynchronizedAfterSuite(func() {
server.Shutdown()
_ = os.RemoveAll(tmpdir)
}, func() {})
var _ = Describe("ActivitylogService", func() {
var (
alog *ActivitylogService
getResource func(_ context.Context, ref *provider.Reference) (*provider.ResourceInfo, error)
writebufferduration = 100 * time.Millisecond
)
JustBeforeEach(func() {
var err error
stream := &eventsmocks.Stream{}
stream.EXPECT().Consume(mock.Anything, mock.Anything).Return(nil, nil)
alog, err = New(
Config(&config.Config{
Service: config.Service{
Name: "activitylog-test",
},
Store: config.Store{
Store: "nats-js-kv",
Nodes: []string{server.Addr().String()},
Database: "activitylog-test-" + uuid.New().String(),
},
MaxActivities: 4,
WriteBufferDuration: writebufferduration,
}),
Stream(stream),
TraceProvider(noop.NewTracerProvider()),
Mux(chi.NewMux()),
)
Expect(err).ToNot(HaveOccurred())
})
Context("with a noop debouncer", func() {
BeforeEach(func() {
writebufferduration = 0
})
Describe("AddActivity", func() {
type testCase struct {
Name string
Tree map[string]*provider.ResourceInfo
Activities map[string]string
Expected map[string][]RawActivity
}
testCases := []testCase{
{
Name: "simple",
Tree: map[string]*provider.ResourceInfo{
"base": resourceInfo("base", "parent"),
"parent": resourceInfo("parent", "spaceid"),
"spaceid": resourceInfo("spaceid", "spaceid"),
},
Activities: map[string]string{
"activity": "base",
},
Expected: map[string][]RawActivity{
"base": activitites("activity", 0),
"parent": activitites("activity", 1),
"spaceid": activitites("activity", 2),
},
},
{
Name: "two activities on same resource",
Tree: map[string]*provider.ResourceInfo{
"base": resourceInfo("base", "parent"),
"parent": resourceInfo("parent", "spaceid"),
"spaceid": resourceInfo("spaceid", "spaceid"),
},
Activities: map[string]string{
"activity1": "base",
"activity2": "base",
},
Expected: map[string][]RawActivity{
"base": activitites("activity1", 0, "activity2", 0),
"parent": activitites("activity1", 1, "activity2", 1),
"spaceid": activitites("activity1", 2, "activity2", 2),
},
},
// Add other test cases here...
}
for _, tc := range testCases {
Context(tc.Name, func() {
JustBeforeEach(func() {
getResource = func(_ context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return tc.Tree[ref.GetResourceId().GetOpaqueId()], nil
}
for k, v := range tc.Activities {
err := alog.addActivity(context.Background(), reference(v), nil, k, time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
}
})
It("should match the expected activities", func() {
for id, acts := range tc.Expected {
activities, err := alog.Activities(resourceID(id))
Expect(err).NotTo(HaveOccurred(), tc.Name+":"+id)
Expect(activities).To(ConsistOf(acts), tc.Name+":"+id)
}
})
})
}
})
})
Context("with a debouncing debouncer", func() {
var (
tree = map[string]*provider.ResourceInfo{
"base": resourceInfo("base", "parent"),
"parent": resourceInfo("parent", "spaceid"),
"spaceid": resourceInfo("spaceid", "spaceid"),
}
)
BeforeEach(func() {
writebufferduration = 100 * time.Millisecond
})
Describe("addActivity", func() {
var (
getResource = func(_ context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return tree[ref.GetResourceId().GetOpaqueId()], nil
}
)
It("debounces activities", func() {
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0)))
}).Should(Succeed())
})
It("adheres to the MaxActivities setting", func() {
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(len(activities)).To(Equal(1))
}).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(len(activities)).To(Equal(2))
}).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity5", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity2", 0, "activity3", 0, "activity4", 0, "activity5", 0)))
}).Should(Succeed())
})
})
Describe("Activities", func() {
It("combines multiple batches", func() {
getResource = func(_ context.Context, ref *provider.Reference) (*provider.ResourceInfo, error) {
return tree[ref.GetResourceId().GetOpaqueId()], nil
}
err := alog.addActivity(context.Background(), reference("base"), nil, "activity1", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity2", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0)))
}).Should(Succeed())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity3", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
err = alog.addActivity(context.Background(), reference("base"), nil, "activity4", time.Time{}, getResource)
Expect(err).NotTo(HaveOccurred())
Eventually(func(g Gomega) {
activities, err := alog.Activities(resourceID("base"))
g.Expect(err).NotTo(HaveOccurred())
g.Expect(activities).To(ConsistOf(activitites("activity1", 0, "activity2", 0, "activity3", 0, "activity4", 0)))
}).Should(Succeed())
})
})
})
})
func activitites(acts ...any) []RawActivity {
var activities []RawActivity
act := RawActivity{}
for _, a := range acts {
switch v := a.(type) {
case string:
act.EventID = v
case int:
act.Depth = v
activities = append(activities, act)
}
}
return activities
}
func resourceID(id string) *provider.ResourceId {
return &provider.ResourceId{
StorageId: "storageid",
OpaqueId: id,
SpaceId: "spaceid",
}
}
func reference(id string) *provider.Reference {
return &provider.Reference{ResourceId: resourceID(id)}
}
func resourceInfo(id, parentID string) *provider.ResourceInfo {
return &provider.ResourceInfo{
Id: resourceID(id),
ParentId: resourceID(parentID),
Space: &provider.StorageSpace{
Root: resourceID("spaceid"),
},
}
}